2026-01-20 21:52:44 +00:00
# git-pkgs proxy
A caching proxy for package registries. Speeds up package downloads by caching artifacts locally, reducing bandwidth usage and improving reliability.
2026-03-04 19:07:53 +00:00
## Version Cooldown
Most supply chain attacks rely on speed: a malicious version gets published and consumed by automated pipelines within minutes, before anyone notices. The cooldown feature adds a quarantine period to newly published versions. When enabled, the proxy strips versions from metadata responses until they've aged past a configurable threshold.
```yaml
cooldown:
default: "3d" # hide versions published less than 3 days ago
ecosystems:
npm: "7d" # npm gets a longer window
cargo: "0" # disable for cargo
packages:
"pkg:npm/lodash": "0" # exempt trusted packages
```
A 3-day cooldown means that when `lodash` publishes version `4.18.0` , your builds keep using `4.17.21` until 3 days have passed. If the new release turns out to be compromised, you were never exposed.
2026-04-06 13:27:05 +01:00
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.
2026-03-04 19:07:53 +00:00
2026-09-03 05:06:44 -04:00
## 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.
```yaml
scanning:
enabled: true
signing_key: ${PROXY_SCANNING_SIGNING_KEY}
scanners:
- name: clamav
url: http://clamav-adapter:8080/scan
mode: block # a block verdict deletes the artifact and returns 403
- name: trivy
url: http://trivy-adapter:8081/scan
mode: monitor # findings are logged, never gate caching
ecosystems: [npm, pypi]
```
The proxy never uploads artifact bytes to a scanner. Each scanner is notified with package metadata plus a short-lived signed URL; the scanner pulls the bytes itself from the proxy's own storage. Scanners run concurrently, and the first `block` -mode scanner to report a verdict of not-allowed wins immediately, canceling the rest. See [docs/configuration.md ](docs/configuration.md ) for the full config reference and the scanner HTTP contract.
2026-01-20 21:52:44 +00:00
## Supported Registries
2026-03-12 12:18:27 +00:00
| Registry | Language/Platform | Cooldown | Completed |
|----------|-------------------|:--------:|:---------:|
| npm | JavaScript | Yes | ✓ |
2026-04-01 11:15:07 -06:00
| Cargo | Rust | Yes | ✓ |
2026-04-06 11:11:00 +01:00
| RubyGems | Ruby | Yes | ✓ |
2026-03-12 12:18:27 +00:00
| Go proxy | Go | | ✓ |
2026-04-06 12:55:15 +01:00
| Hex | Elixir | Yes* | ✓ |
2026-03-12 12:18:27 +00:00
| pub.dev | Dart | Yes | ✓ |
| PyPI | Python | Yes | ✓ |
| Maven | Java | | ✓ |
2026-05-04 12:15:16 +02:00
| Gradle Build Cache | Java/Kotlin | | ✓ |
2026-04-06 13:12:18 +01:00
| NuGet | .NET | Yes | ✓ |
2026-03-12 12:18:27 +00:00
| Composer | PHP | Yes | ✓ |
| Conan | C/C++ | | ✓ |
2026-04-06 13:16:00 +01:00
| Conda | Python/R | Yes | ✓ |
2026-03-12 12:18:27 +00:00
| CRAN | R | | ✓ |
2026-05-13 06:46:35 +01:00
| Julia | Julia | | ✓ |
2026-09-02 13:06:34 +01:00
| Swift | Swift | | ✓ |
2026-03-12 12:18:27 +00:00
| Container | Docker/OCI | | ✓ |
2026-09-03 16:59:12 +01:00
| Homebrew | macOS/Linux | | ✓ |
2026-03-12 12:18:27 +00:00
| Debian | Debian/Ubuntu | | ✓ |
| RPM | RHEL/Fedora | | ✓ |
Add Alpine APK repository proxy support (#293)
* Add Alpine APK repository proxy support
- Serve named APK repositories at /apk/{repository}/ with the official
Alpine mirror as the default repository
- Cache v2 APKINDEX.tar.gz and v3 Packages.adb indexes and detached
signatures via the metadata cache, serving stored bytes unchanged so
apk signature verification keeps working
- Cache .apk packages in the shared artifact cache keyed by the full
repository path, since APK filenames do not include the architecture
- Add configurable upstream repositories via upstream.apk with
validation, plus dashboard registry instructions
- Add tests for index/signature byte fidelity, per-arch caching, cache
hits, offline reads, upstream authentication, and 404 handling
- Document apk usage in README, config example, and configuration docs
* Serve APK package HEAD requests without a body
Use the method-aware serveArtifact helper (as container.go does) so HEAD
responses carry Content-Length but omit the body; add a regression test.
* Drop doubled blank line in docs/configuration.md
---------
Co-authored-by: Andrew Nesbitt <andrewnez@gmail.com>
2026-09-02 13:36:00 +02:00
| Alpine | Alpine Linux | | ✓ |
2026-10-11 23:41:16 +02:00
| Arch | Arch Linux | | ✓ |
2026-10-19 12:29:39 +05:30
| Chef Supermarket | Chef | | ✓ |
Add generic HTTP download proxy for GitHub release assets (mise/aqua) (#302)
* Add generic HTTP download proxy for GitHub release assets
Adds a /generic/{name}/ route backed by a new upstream.generic named-upstream
map, so tools that download from fixed URL shapes (mise's aqua backend
fetching GitHub release assets, and its tag lookups on api.github.com) can be
pointed at the proxy with client-side URL rewriting. Only configured
upstreams are reachable, so this is not an open HTTP proxy.
Paths shaped like {owner}/{repo}/releases/download/{tag}/{asset} are
version-pinned and go through the artifact cache: fetched once, hashed,
served without revalidation, and still served when the upstream is down.
Every other path goes through the metadata cache with the client's Accept
header and query string replayed, so API responses are fresh within
metadata_ttl, revalidated after that, and served stale when the upstream
fails or rate-limits the request.
Tests cover path classification, unknown upstreams and traversal, cache
hits with the upstream down, HEAD, 404 pass-through, Accept and query
forwarding, stale-on-429, cache isolation between upstreams, and that an
upstream token scoped to the release host is not sent to the object store
it redirects to.
Closes #183.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDjDeq27CKzEP2o3GWBY7F
* Use fixed Accept header for generic metadata
---------
Co-authored-by: Giles Westwood <giles@gileswestwood.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 16:44:54 +01:00
| Generic | Any | | ✓ |
2026-10-08 09:58:56 +02:00
| Helm | Kubernetes | Yes | ✓ |
2026-03-12 12:18:27 +00:00
| 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.
2026-01-20 21:52:44 +00:00
2026-04-06 12:55:15 +01:00
\* Hex cooldown requires disabling registry signature verification (`HEX_NO_VERIFY_REPO_ORIGIN=1` ) since the proxy re-encodes the protobuf payload.
2026-04-13 11:03:59 +01:00
## Install
```bash
brew install git-pkgs/git-pkgs/proxy
```
Or download a binary from the [releases page ](https://github.com/git-pkgs/proxy/releases ).
2026-09-04 10:52:43 +01:00
### Helm
Install the chart from GHCR, setting the public URL that package-manager clients
will use to reach the proxy:
```bash
helm install proxy oci://ghcr.io/git-pkgs/charts/proxy \
--set config.data.base_url=https://proxy.example.com
```
The default chart deploys one replica backed by a 10 GiB persistent volume,
using SQLite and filesystem artifact storage under `/data` . See
[`deploy/charts/proxy/values.yaml` ](deploy/charts/proxy/values.yaml ) for ingress,
external database and object-storage configuration options.
2026-01-20 21:52:44 +00:00
## Quick Start
```bash
# Build from source
go build -o proxy ./cmd/proxy
# Run with defaults (listens on :8080)
./proxy
# Run with custom settings
./proxy -listen :3000 -base-url https://proxy.example.com
```
The proxy is now running. Configure your package managers to use it.
2026-03-11 17:18:29 +00:00
## OpenAPI (Swagger)
This repo uses swaggo to generate an OpenAPI spec from annotated handlers.
Generate the spec:
```bash
go install github.com/swaggo/swag/cmd/swag@latest
go generate ./internal/server
```
Generated files are written to `docs/swagger/` .
When the proxy is running, fetch the live spec from:
- `http://localhost:8080/openapi.json`
Or replace `http://localhost:8080` with your configured base URL. This link is also shown on the dashboard.
2026-01-20 21:52:44 +00:00
## Configuring Package Managers
### npm
Create or edit `~/.npmrc` :
```
registry=http://localhost:8080/npm/
```
Or set per-project in `.npmrc` :
```
registry=http://localhost:8080/npm/
```
Or use environment variable:
```bash
npm_config_registry=http://localhost:8080/npm/ npm install
```
2026-10-01 10:09:33 +02:00
`npm audit` , `pnpm audit` , `yarn npm audit` and `npm audit signatures` work
through the proxy: the audit and signing-key endpoints are passed through to the
configured upstream registry, with upstream authentication applied. Advisories
therefore come from upstream's database, not from the proxy's own vulnerability
data, and versions withheld by [cooldown ](#version-cooldown ) are not excluded
from the report.
2026-09-30 18:45:08 +02:00
Add package routes for private Composer and npm registries (#426)
* Add Composer package routes for private repositories
- Add upstream.composer_routes mapping package name patterns to repositories
- Serve matching packages only from their route, with no fallback to the default repository
- Qualify metadata and artifact cache keys with the route's upstream
- Give routed versions an empty notification-url so installs are not reported to notify-batch
- Document routes in configuration docs, example config and README
* Add npm package routes for private registries
- Add upstream.npm_routes mapping package name patterns to registries
- Serve matching packages only from their route, with no fallback to upstream.npm
- Resolve and validate tarball URLs against the route's registry
- Qualify metadata and artifact cache keys with the route's upstream
- Document npm routes and the audit endpoint limitation
* Share routed artifact filename qualification between handlers
* Match Composer routes without the ~dev suffix
Packagist serves a package's dev versions from {name}~dev.json, and the
handler matched routes against the name with ~dev still attached. An exact
route for example/library therefore missed /p2/example/library~dev.json, and
the private package's dev metadata came from the default repository.
- Match routes against the package name without ~dev, keeping it in the
upstream URL and the cache key so the two files stay apart
- Add a regression test with an exact route
* Keep routed npm publish times out of the shared versions row
The download cooldown check stores publish times under the plain version
PURL, which does not say which registry a version came from. A time read
from the default registry was trusted for a private version of the same
name, so a private release published an hour ago passed a seven-day
cooldown when the public one was a month old.
- Skip the stored row for routed packages, both reading and writing, and
take the publish time from the route's metadata, which is cached under
the route
- This also stops a private time from holding back the public version, and
a route's previous upstream from lending its times to the new one
- Cover adding a route after a public download, the reverse order, and
moving a route to another upstream
2026-10-11 19:06:18 +02:00
To serve private packages through the same registry URL, see [Private package routes ](docs/configuration.md#private-package-routes ).
2026-01-20 21:52:44 +00:00
### Cargo
Create or edit `~/.cargo/config.toml` :
```toml
[source.crates-io]
replace-with = "proxy"
[source.proxy]
registry = "sparse+http://localhost:8080/cargo/"
```
Or set per-project in `.cargo/config.toml` in your project root.
### RubyGems / Bundler
Set the gem source in your `Gemfile` :
```ruby
source "http://localhost:8080/gem"
```
Or configure globally:
```bash
gem sources --add http://localhost:8080/gem/
bundle config mirror.https://rubygems.org http://localhost:8080/gem
```
### Go modules
Set the GOPROXY environment variable:
```bash
export GOPROXY=http://localhost:8080/go,direct
```
Or in your shell profile for persistence.
2026-10-10 09:57:43 +02:00
Checksum lookups go through the same URL: the proxy serves `sum.golang.org` at
`/go/sumdb/sum.golang.org/` , and the `go` command uses it without further
2026-10-10 10:18:40 +02:00
setup, so clients need no route of their own to `sum.golang.org` . If the
2026-10-10 11:31:01 +02:00
proxy loses `https://sum.golang.org` , it keeps answering until its cached
copy of the database's signed tree is older than `metadata_ttl` ; after that
Add upstream.go_sumdbs_offline for networks where only the proxy has egress
On a build network where clients cannot reach sum.golang.org at all, the
proxy should keep answering for the database during an outage, since a 404
on supported would only send the go command somewhere it cannot go. With
the switch set, a cached tree of any age is enough; without it, the tree
has to be fresh, as before.
That does not make every cached module verify. The go command checks a
lookup against tiles of the tree that came with it, and sum.golang.org
attaches a different tree to almost every lookup, so it can ask for a tile
nobody fetched through the proxy before. configuration.md says so.
While the database is known to be unreachable, offline mode also serves
cached lookups and tiles straight away. Otherwise each of them would sit
through the upstream timeout before the fetch falls back to the cache,
and a build would take minutes for as long as the outage lasts. A lookup
or tile that falls back to the cache renews what the proxy knows about
the database, unless a probe has reached it since the request started.
The go command asks supported only once per process, so a long build would
otherwise be back to waiting a minute into the outage. recordTimeout is
now recordFailure, since lookups use it too.
The option needs cache_metadata, since without it there is no cached tree
to fall back on, and the config refuses the combination.
2026-10-10 17:55:04 +02:00
the `go` command contacts the database directly as before. Set
`upstream.go_sumdbs_offline` on networks where clients cannot do that. Other
checksum databases are listed under `upstream.go_sumdbs` , see
2026-10-10 09:57:43 +02:00
[Go checksum database ](docs/configuration.md#go-checksum-database ).
2026-09-03 16:59:12 +01:00
### Homebrew
Point Homebrew's JSON API and artifact domain at the proxy:
```bash
export HOMEBREW_API_DOMAIN=http://localhost:8080/homebrew
export HOMEBREW_ARTIFACT_DOMAIN=http://localhost:8080
```
The artifact domain proxies manifests and bottle blobs under `/v2/homebrew/core/` . GHCR routing is limited to that repository. Source archives, cask application downloads, custom tap artifacts, and legacy flat-file bottle mirrors use Homebrew's normal fallback URLs. Keep fallback enabled by leaving `HOMEBREW_ARTIFACT_DOMAIN_NO_FALLBACK` unset.
Enable `cache_metadata` or set `PROXY_CACHE_METADATA=true` to retain Homebrew JSON API responses for offline fallback. Bottle blobs and their OCI manifests are cached without this setting.
The upstreams default to `https://formulae.brew.sh/api` for the JSON API and `https://ghcr.io` for artifacts. To chain this proxy to another proxy, configure its Homebrew endpoints as the upstreams:
```yaml
upstream:
homebrew_api: "https://upstream-proxy.example.com/homebrew"
homebrew_artifact: "https://upstream-proxy.example.com"
```
The equivalent environment variables are `PROXY_UPSTREAM_HOMEBREW_API` and `PROXY_UPSTREAM_HOMEBREW_ARTIFACT` .
2026-01-20 21:52:44 +00:00
### Hex (Elixir)
Configure in `~/.hex/hex.config` :
```erlang
{default_url, <<"http://localhost:8080/hex">>}.
```
Or set the environment variable:
```bash
export HEX_MIRROR=http://localhost:8080/hex
```
### pub.dev (Dart/Flutter)
Set the PUB_HOSTED_URL environment variable:
```bash
export PUB_HOSTED_URL=http://localhost:8080/pub
```
### PyPI (pip)
Configure pip to use the proxy:
```bash
pip install --index-url http://localhost:8080/pypi/simple/ package_name
```
Or set in `~/.pip/pip.conf` :
```ini
[global]
index-url = http://localhost:8080/pypi/simple/
```
### Maven
Add to your `~/.m2/settings.xml` :
```xml
<settings>
<mirrors>
<mirror>
<id>proxy</id>
<mirrorOf>central</mirrorOf>
<url>http://localhost:8080/maven/</url>
</mirror>
</mirrors>
</settings>
```
2026-05-22 18:05:20 +02:00
The `/maven/` endpoint uses Maven Central as primary upstream and falls back to the Gradle Plugin Portal for Gradle plugin marker metadata and related artifacts when the primary upstream returns not found.
For Gradle plugin resolution via the same proxy endpoint:
```kotlin
pluginManagement {
repositories {
maven(url = "http://localhost:8080/maven/")
}
}
```
2026-05-04 12:15:16 +02:00
### Gradle HTTP Build Cache
2026-10-18 19:31:46 +02:00
Configure in `settings.gradle.kts` :
2026-05-04 12:15:16 +02:00
```kotlin
buildCache {
local {
enabled = false
}
remote<HttpBuildCache> {
url = uri("http://localhost:8080/gradle/")
2026-10-18 19:31:46 +02:00
isPush = System.getenv("CI") != null
2026-05-04 12:15:16 +02:00
}
}
```
2026-10-18 19:31:46 +02:00
The proxy rejects uploads unless `gradle.build_cache.read_only` is set to `false` , see [docs/configuration.md ](docs/configuration.md#gradle-build-cache ).
2026-01-20 21:52:44 +00:00
### NuGet
Configure in `nuget.config` :
```xml
<configuration>
<packageSources>
<clear />
<add key="proxy" value="http://localhost:8080/nuget/v3/index.json" />
</packageSources>
</configuration>
```
Or use the CLI:
```bash
dotnet nuget add source http://localhost:8080/nuget/v3/index.json -n proxy
```
### Composer (PHP)
Configure in `composer.json` :
```json
{
"repositories": [
{
"type": "composer",
"url": "http://localhost:8080/composer"
}
]
}
```
Or set globally:
```bash
composer config -g repositories.proxy composer http://localhost:8080/composer
```
Add package routes for private Composer and npm registries (#426)
* Add Composer package routes for private repositories
- Add upstream.composer_routes mapping package name patterns to repositories
- Serve matching packages only from their route, with no fallback to the default repository
- Qualify metadata and artifact cache keys with the route's upstream
- Give routed versions an empty notification-url so installs are not reported to notify-batch
- Document routes in configuration docs, example config and README
* Add npm package routes for private registries
- Add upstream.npm_routes mapping package name patterns to registries
- Serve matching packages only from their route, with no fallback to upstream.npm
- Resolve and validate tarball URLs against the route's registry
- Qualify metadata and artifact cache keys with the route's upstream
- Document npm routes and the audit endpoint limitation
* Share routed artifact filename qualification between handlers
* Match Composer routes without the ~dev suffix
Packagist serves a package's dev versions from {name}~dev.json, and the
handler matched routes against the name with ~dev still attached. An exact
route for example/library therefore missed /p2/example/library~dev.json, and
the private package's dev metadata came from the default repository.
- Match routes against the package name without ~dev, keeping it in the
upstream URL and the cache key so the two files stay apart
- Add a regression test with an exact route
* Keep routed npm publish times out of the shared versions row
The download cooldown check stores publish times under the plain version
PURL, which does not say which registry a version came from. A time read
from the default registry was trusted for a private version of the same
name, so a private release published an hour ago passed a seven-day
cooldown when the public one was a month old.
- Skip the stored row for routed packages, both reading and writing, and
take the publish time from the route's metadata, which is cached under
the route
- This also stops a private time from holding back the public version, and
a route's previous upstream from lending its times to the new one
- Cover adding a route after a public download, the reverse order, and
moving a route to another upstream
2026-10-11 19:06:18 +02:00
To serve private packages through the same URL, see [Private package routes ](docs/configuration.md#private-package-routes ).
2026-01-20 21:52:44 +00:00
### Conan (C/C++)
Add the proxy as a remote:
```bash
conan remote add proxy http://localhost:8080/conan
conan remote disable conancenter
```
Or configure in `~/.conan2/remotes.json` .
2026-10-19 12:29:39 +05:30
### Chef Supermarket (Berkshelf, Policyfiles)
Point Berkshelf at the proxy in your `Berksfile` :
```ruby
source "http://localhost:8080/chef"
```
Or set the default source of a Policyfile:
```ruby
default_source :supermarket, "http://localhost:8080/chef"
```
The proxy serves the `/universe` dependency index and the cookbook API, rewriting every download location to itself while leaving dependency constraints unchanged. Cookbook archives are fetched through the Supermarket download redirect and cached, so they stay available when the Supermarket is down. With `cache_metadata` on, the universe and cookbook metadata follow the usual TTL and stale fallback.
To proxy a private Supermarket instead of `supermarket.chef.io` , set `upstream.chef` (or `PROXY_UPSTREAM_CHEF` ) and add credentials under `upstream.auth` if it needs them. See [docs/configuration.md ](docs/configuration.md#upstream-registries ).
2026-01-20 21:52:44 +00:00
### Conda
Configure in `~/.condarc` :
```yaml
channels:
- http://localhost:8080/conda/main
- http://localhost:8080/conda/conda-forge
default_channels:
- http://localhost:8080/conda/main
```
Or set via command:
```bash
conda config --add channels http://localhost:8080/conda/main
```
### CRAN (R)
Set the repository in R:
```r
options(repos = c(CRAN = "http://localhost:8080/cran"))
```
Or in `~/.Rprofile` for persistence:
```r
local({
r <- getOption("repos")
r["CRAN"] <- "http://localhost:8080/cran"
options(repos = r)
})
```
2026-05-13 06:46:35 +01:00
### Julia
Set the Pkg server before starting Julia:
```bash
export JULIA_PKG_SERVER=http://localhost:8080/julia
```
Or inside a running session:
```julia
ENV["JULIA_PKG_SERVER"] = "http://localhost:8080/julia"
using Pkg; Pkg.update()
```
2026-09-02 13:06:34 +01:00
### Swift
Configure the proxy as the default registry for the current Swift package:
```bash
swift package-registry set --allow-insecure-http http://localhost:8080/swift
```
Registry dependencies use their scoped package identifier in `Package.swift` :
```swift
dependencies: [
.package(id: "apple.swift-argument-parser", from: "1.2.0")
]
```
The proxy supports dependency resolution and source downloads. Publishing with
`swift package-registry publish` is not supported.
2026-01-29 19:35:15 +00:00
### Docker / Container Registry
Configure Docker to use the proxy as a registry mirror in `/etc/docker/daemon.json` :
```json
{
"registry-mirrors": ["http://localhost:8080"]
}
```
Then restart Docker:
```bash
sudo systemctl restart docker
```
Or pull images directly:
```bash
docker pull localhost:8080/library/nginx:latest
```
2026-10-14 13:17:37 +01:00
The registry is pull-only. BuildKit cache exports (`--cache-to type=registry` )
and other pushes need a separate registry.
Proxy the OCI referrers API in the container handler (#427)
* Proxy the OCI referrers API in the container handler
GET /v2/{name}/referrers/{digest} used to fall through to a plain 404,
so clients only ever saw referrers stored under the sha256-<hex> tag
schema. Registries that keep SBOMs and signatures behind the referrers
API looked empty through the proxy.
The index is now fetched from the upstream that the repository routes
to and cached like the tag list: fresh within the metadata TTL,
revalidated with If-None-Match, and served stale when the upstream is
down. artifactType is not forwarded, so one cached row covers every
filter and clients filter the full list themselves, which they do when
OCI-Filters-Applied is absent. An upstream 404 is passed through
untouched, so clients still fall back to the tag schema for registries
like GHCR. When the upstream cannot answer and nothing is cached, the
proxy keeps answering 404 as before rather than failing the request.
Closes #144
* Cover absolute upstream links in the referrers link test
The second page now answers with an absolute Link pointing back at the
upstream host, and the test checks that it comes back rewritten to the
proxy. That was missing so far, and it also gives the upstream variable
a reason to be declared before the handler closure, which staticcheck
complained about.
* Read cached referrers through a small shared loader
loadContainerReferrers was a line-for-line copy of loadContainerTags with
the types renamed, which dupl flags in CI. The part that opens the cache
row and reads the body now lives in loadContainerMetadata next to
storeContainerMetadata, and the referrers loader only maps the row onto
its struct. The tag and manifest loaders are left as they are.
* Make the manifest routing test notice which handler answered
For foo/manifests/referrers/<digest> the manifest handler and the
referrers handler send the very same upstream path, so the test passed
even with the referrers case moved in front of the manifest case. The
fake upstream now rejects requests that only accept an image index,
which is what the referrers handler sends.
* Check that a cached referrers row links back to whoever reads it
A repository can be reached as upstream/<name>/<repo> and, when the
default registry is the same host, as plain <repo>. Both end up in the
same cache row, so the pagination Link has to be rewritten when the row
is served, not when it is stored. The link test now reads the row
through both paths; with a store-time rewrite the second path would get
the first one's link.
* Make the referrers ETag test prove the revalidation
Without If-None-Match the fake upstream just sends the full index again,
so the old assertions held either way. The test now counts the 304s the
upstream sent and expects exactly one.
* Reword which tools use the referrers API in the README
The cosign mention was too broad. cosign reaches the referrers API for
its newer signature bundles, while the old .sig tags keep going through
the normal manifest path. Also rewraps the paragraph, one line had run
past the others.
* Check that a 304 restarts the referrers cache TTL
The ETag test now backdates the cached row instead of running with a
zero TTL. After the upstream answers 304, a third request has to come
from the cache. Before, the test would also have passed if the 304
branch forgot to bump fetchedAt and every request kept revalidating.
* Test the referrers Content-Type fallback
None of the fake upstreams left out Content-Type, so the default to the
image index type was never exercised. oras-go rejects anything else, so
it is worth pinning, both for the first fetch and for the cached copy.
* Test referrers digests with extra characters around them
The invalid-request table only had digests that were too short or used
an unknown algorithm, so it would not notice if the digest pattern lost
its anchors. Two cases now put junk before and after an otherwise valid
sha256 digest.
* Reuse the manifest cache key for referrers
The referrers handler had its own key function, but it did exactly what
containerManifestCacheKey already does with four strings. Referrers rows
sit in their own oci-referrers ecosystem anyway, so they can't collide
with manifests.
* Drop the separate struct for cached referrers
A cached referrers response carries the same fields as a cached tag
list, and the freshness check was a straight copy too. So the referrers
code just uses cachedContainerTags and containerTagsFresh now. Loading,
storing and writing stay on their own, since the ecosystem and the Link
handling are different.
* Route referrers requests by ns like the other OCI endpoints
Now that containerd ns support is on main, referrers requests have to
go through registryForRequest too, otherwise a _default mirror would
look every subject up on Docker Hub. ns only picks the registry, so it
is dropped from the forwarded query and the cache key, and the next-page
Link keeps it, the same way tag lists do. This also follows the new
rewriteContainerTagsLink signature, which takes the namespace.
* Without a cached index, referrers now pass upstream errors through and answer 502 when the upstream can't be reached or sends garbage, so clients no longer read an outage as a registry without the referrers API. Stale cache is still served when there is one.
* The tag list and manifest routes now only match paths that end in their endpoint, so a repository with tags/list or manifests in its name can still reach the referrers endpoint.
2026-10-12 10:08:01 +02:00
SBOMs, signatures and attestations attached to an image are found through the
OCI 1.1 referrers API (`GET /v2/{name}/referrers/{digest}` ). `oras discover`
and `notation verify` use it, and so does cosign for its newer signature
bundles. The proxy forwards these requests and caches the returned index with
the same TTL as other metadata, so they keep working offline. It does not
filter by `artifactType` ; it returns the full list and the client filters it,
as the spec allows. Registries without the referrers API (GHCR, for example)
answer 404, and clients then look up the `sha256-<digest>` tag instead, which
the proxy serves like any other manifest. The proxy is pull-only, so attaching
new artifacts through it is not supported.
Route containerd ns mirror requests to configured registries (#405)
* Route containerd ns mirror requests to configured registries
containerd's hosts.toml mirrors append ?ns=<registry-host> to every
request. The container handler ignored it, so a single _default mirror
entry could not serve more than one registry.
ns is a closed-world lookup key and is never dialed. Docker Hub aliases
and the host of upstream.oci_default select the default route, hosts of
upstream.oci entries select their named upstream. Unknown hosts return
NAME_UNKNOWN so containerd falls back to its next host. Registry URLs
with a path are not indexed, because ns names the registry at the root
of a host. Host collisions log a warning and resolve deterministically.
With ns the path is the verbatim upstream repository, the reserved
upstream/ prefix is rejected, and repository prefix routes such as
Homebrew's do not apply. Cache names match the unprefixed and
upstream/{name}/ routes, so all routes share cached blobs, manifests and
tag lists. ns is no longer forwarded on tag-list requests, and the
pagination Link is rewritten per request instead of at store time so
clients on different routes get links for their own route.
Refs #303
* Document containerd ns mirroring
Refs #303
* Version the tag-list cache identity for the raw-link format
Tag-list rows now store the upstream Link verbatim and rewrite it per
request. Rows written by earlier versions hold a Link already rewritten
for one route. Under the unchanged key an older binary running next to
this one (rolling update on shared Postgres, or a rollback) would serve
the raw Link unrewritten, sending clients to the wrong route for the
next page. A format marker in the key keeps the two apart at the cost of
one cache miss per tag list after the upgrade.
Adversarial review finding F1: tag-list cache rows hold raw upstream
Links under unchanged keys, so an older binary serves them unrewritten.
* Qualify the README claim about ns mirror coverage
Registries whose URL has a path and hosts shared by two entries are not
reachable through ns; point to the configuration guide for both rules.
Adversarial review finding F3: README says ns covers every upstream.oci
registry.
* Redact registry URLs in ns index warnings
Configured registry URLs may carry userinfo credentials, which the new
startup warnings wrote to the log verbatim. Log them with the password
masked, and say precisely what a path-prefixed default registry means:
its host is not indexed, while the Docker Hub aliases still select it.
Adversarial review finding F2: startup warnings print registry URLs
unredacted and the default-registry message is misleading.
* Keep non-default ports apart in the ns index
Dropping 80 and 443 regardless of scheme turned https://host:80 and
https://host into the same key, so a request for one of them could land
on the other registry. Only the default port of the URL's scheme is
optional now. A configured host is indexed with and without that port,
because image references spell it either way; any other port has to
match exactly, and the ns value is used as containerd sends it, apart
from case and IPv6 brackets.
Since a URL now yields two keys that can belong to different routes,
the collision warning lists the owner per key.
The handler test runs two registries on one host that differ only in
the port. Tests can't bind 80 or 443, so a dialer maps those addresses
to the fake servers.
* Allow upstream/{name}/ together with ns when they agree
containerd sends ns on every request to a mirror host, override_path or
not. Per-registry mirrors pointing at /v2/upstream/{name} therefore
arrive as upstream/{name}/...?ns=<registry>, and refusing every prefixed
name under ns broke them, including the only way to mirror an upstream
whose URL has a path.
Such requests are now handled like the prefix route without ns, as long
as ns names that upstream's own host under the same port rules as the
index. Any other ns on a prefixed name is still NAME_UNKNOWN, so ns
can't be used to create cache entries under another registry's name.
* README: containerd config_path and how to check it
A _default/hosts.toml does nothing while CRI has no hosts directory
configured, so add the config.toml snippet for containerd 2.x and 1.x.
To check the setup, look at containerd config dump and pull with
crictl, then find the request in the proxy log; ctr --hosts-dir reads
the directory on its own and would succeed even without config_path.
Also mention that existing per-registry override_path entries keep
working and that k3s generates the directory itself.
* Let the prefix route take ns for aliases and mirrored registries
With upstream.oci.hub pointing at registry-1.docker.io and a docker.io
hosts.toml mirror on /v2/upstream/hub, containerd sends ns=docker.io,
and the prefix check only accepted the upstream's own host. The same
happens whenever the upstream is a mirror of the registry the nodes
pull from, say an Artifactory remote for ghcr.io: ns carries ghcr.io,
the upstream host is something else, and the pull got a 404.
The prefix already decides where the content comes from and which
cache entries are used, so ns can't redirect anything there. The check
now only refuses an ns that contradicts the prefix, meaning a host the
proxy knows that belongs to another route. The Docker Hub aliases count
as one host, and a host the proxy doesn't know at all is accepted,
since a client can only arrive at the prefix through its own mirror
entry.
* Take docker.io on every prefix route
A Docker Hub mirror configured as a named upstream (mirror.gcr.io, an
Artifactory remote) with a docker.io hosts.toml pointing at
/v2/upstream/hub got a 404 again: docker.io is always in the index for
the default route, so the check saw a known host of another route. That
setup worked before this branch. Docker Hub repository names have
exactly two path components, so docker.io/upstream/... can never be a
real image, and accepting the aliases on every prefix route shadows
nothing.
While here: the collision warning claimed ns requests go to the other
route, which is only true without the prefix, and the README said pulls
for unknown registries always get a 404, which isn't the case for paths
under upstream/{name}/.
* Only take a foreign ns on a prefix route when it is configured
A containerd _default mirror sends the same request for a pull of
unconfigured.example/upstream/mirror/owner/app as a ghcr.io hosts.toml
with override_path does for a mirror upstream. Accepting any host the
proxy didn't know therefore let such a pull be answered with owner/app
from the mirror upstream, a different image, instead of a 404 that lets
containerd go to the registry the image actually names.
The prefix route now accepts ns only for the upstream's own host, for
Docker Hub, and for hosts listed in the new upstream.oci_mirrors setting.
That setting is how an Artifactory remote of ghcr.io keeps working:
upstream:
oci_mirrors:
ghcr: ["ghcr.io"]
The listed hosts also select the upstream for unprefixed ns requests,
which makes a mirror with a path in its URL reachable through the
_default entry as well. Hosts from configured URLs still win over
mirror entries; the proxy warns about such overlaps at startup.
* Fix the docs sentence on which ns a prefix route takes
configuration.md still said a prefix request with ns is only refused
when ns names another route's host. Since the last commit it is the
other way round: only the upstream's own host, its oci_mirrors entries
and Docker Hub get through.
* Note what a host accepted on a prefix route gives up
Listing ghcr.io for an upstream (or the upstream's own host) still has
the ambiguity from the wildcard case: ghcr.io/upstream/ghcr/owner/app
lands on that upstream. That is the price of an explicit entry and
fine in practice, but it should be written down.
* Test that oci_mirrors entries stay with their own upstream
Two gaps in the coverage of the last change: nothing failed if a host
listed for one upstream was also accepted on another upstream's prefix,
and nothing checked that the server actually hands upstream.oci_mirrors
to the container handler. The handler test now has a second upstream
that must refuse ghcr.io, and a small server test pulls through a
mirror entry on both the prefix and the unprefixed route.
* Log when a prefix route refuses an ns
Setups with a ghcr.io hosts.toml pointing at a mirror upstream worked
before this branch and now need an oci_mirrors entry. Without one,
containerd gets a 404, quietly pulls from ghcr.io directly, and nobody
notices the proxy is being skipped. The refusal is now logged as a
warning that names the upstream and the ns and points at
upstream.oci_mirrors.
* Drop a check in validateOCIMirrors that could never fire
user@ghcr.io parses with ghcr.io as its host, so the parsed.Host != host
comparison already rejects it and the extra parsed.User test was dead.
A test case for a host with credentials keeps that path covered.
* Reject an oci_mirrors host that is listed for two upstreams
With ghcr.io under both art and nexus, both prefix routes took ns=ghcr.io
while unprefixed requests went to whichever name sorted first, with
nothing but a startup warning. Config validation now refuses the second
entry, comparing hosts without case and without the default :443.
* Log each refused ns once per upstream and refuse overlong values
The refusal warning fired on every request, and a node with a missing
oci_mirrors entry asks for the manifest and each layer, so the same line
piled up. Each upstream/ns pair is now logged once, for up to 1024
pairs; after that every refusal is logged again.
ns comes straight from the client and can be as long as the request
line allows, so remembering it as is would let anyone pin a lot of
memory with a handful of requests. No registry host is longer than 259
characters (a 253-character name plus a port), so anything longer is
refused right away, before it is logged or remembered.
* Compare oci_mirrors hosts the way the handler does
The duplicate check lowercased the host and trimmed :443, while the
handler splits host and port, brackets IPv6 literals and drops an empty
port. So ghcr.io and ghcr.io:, or [::1] and [::1]:, passed as different
hosts in two upstreams and then collided in the handler. The check now
builds the same key the handler uses.
* Rewrap the oci_mirrors paragraphs in the configuration docs
Two sentences had been dropped into the middle of wrapped lines, and the
prefix-route paragraph named Docker Hub twice in a row. Same content,
shorter lines, one mention.
* Stop logging ns refusals once the set is full
Past 1024 different upstream/ns pairs every refusal was logged again,
so anyone could fill the set with made-up hosts and then flood the log
at warning level. When the set is full the proxy now says so once and
keeps quiet after that. A real misconfiguration shows up long before
that, with the first pull.
* Test the limit on remembered ns refusals
Nothing covered what happens once 1024 pairs are remembered. The new
test fills the set, then sends a known pair and two new ones: the proxy
must log the limit notice exactly once, log none of the refusals and
keep the set at its size.
2026-10-10 08:35:50 +02:00
#### containerd (Kubernetes, k3s, nerdctl)
containerd mirrors send the original registry host in an `ns` query
parameter, so one mirror entry can serve Docker Hub and the registries
configured in `upstream.oci` whose URL has no path. Point containerd's CRI
plugin at a hosts directory in `/etc/containerd/config.toml` and restart
containerd. For containerd 2.x:
```toml
version = 3
[plugins."io.containerd.cri.v1.images".registry]
config_path = "/etc/containerd/certs.d"
```
For containerd 1.x:
```toml
version = 2
[plugins."io.containerd.grpc.v1.cri".registry]
config_path = "/etc/containerd/certs.d"
```
Then create `/etc/containerd/certs.d/_default/hosts.toml` :
```toml
[host."http://proxy.example.com:8080"]
capabilities = ["pull", "resolve"]
```
To check that CRI picked up the directory, confirm the setting containerd
runs with and pull through CRI (on k3s use `k3s crictl` ):
```bash
containerd config dump | grep config_path
crictl pull docker.io/library/nginx:latest
```
The proxy logs a `container manifest request` line for the pull and, when
`access_log.path` is set, an access-log entry. `ctr images pull --hosts-dir`
is no substitute for this check: it reads the directory itself and succeeds
even while CRI still has no `config_path` .
`docker.io` and the host of `upstream.oci_default` use the default registry;
the host of each `upstream.oci` URL uses that named registry, and so do the
hosts listed for it in `upstream.oci_mirrors` . Pulls for any other registry get
`404 NAME_UNKNOWN` , and containerd falls back to the registry itself. Existing
per-registry `hosts.toml` files that point at `/v2/upstream/{name}` with
`override_path = true` keep working for that upstream's own host and for
Docker Hub. If the upstream is a mirror of the registry the nodes pull from,
say an Artifactory remote of `ghcr.io` , list that registry in
`upstream.oci_mirrors` (`ghcr: ["ghcr.io"]` ); otherwise those pulls get the
404 as well. See [docs/configuration.md ](docs/configuration.md ) for how
hosts are matched and which entry wins when two share a host. k3s generates
the hosts directory itself from `/etc/rancher/k3s/registries.yaml` ; `mirrors:
{"*": {endpoint: ["http://proxy.example.com:8080"]}}` produces the same
`_default` entry.
2026-08-16 22:42:55 +05:30
### Helm
Configure each HTTP chart repository with a name, then add the matching proxy
URL to Helm:
```yaml
upstream:
helm:
bitnami: "https://charts.bitnami.com/bitnami"
```
```bash
helm repo add bitnami http://localhost:8080/helm/bitnami
helm repo update
helm pull bitnami/nginx
```
The proxy caches `index.yaml` using the normal metadata-cache settings and
caches chart archives after verifying their SHA-256 digest from the index.
For charts stored in an OCI registry, configure a named OCI upstream and add
the reserved `upstream/{name}` prefix to the chart reference:
```yaml
upstream:
oci:
ghcr: "https://ghcr.io"
```
```bash
helm pull oci://localhost:8080/upstream/ghcr/owner/charts/mychart --version 1.0.0 --plain-http
```
2026-01-29 19:35:15 +00:00
### Debian / APT
Configure APT to use the proxy in `/etc/apt/sources.list.d/proxy.list` :
```
deb http://localhost:8080/debian stable main contrib
```
2026-10-11 17:58:45 +02:00
Or in deb822 format, in `/etc/apt/sources.list.d/proxy.sources` :
```
Types: deb
URIs: http://localhost:8080/debian
Suites: stable
Components: main contrib
Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg
```
Replace your existing sources.list entries (on newer releases also
`debian.sources` or `ubuntu.sources` in `/etc/apt/sources.list.d/` ), then:
2026-01-29 19:35:15 +00:00
```bash
sudo apt update
```
2026-10-11 17:58:45 +02:00
There is no proxy key to install. The proxy serves the upstream's signed
`InRelease` and `Release` files unchanged, so APT verifies them with the
upstream archive's own key from the distribution's keyring package:
`debian-archive-keyring` on Debian, `ubuntu-keyring` on Ubuntu
(`/usr/share/keyrings/ubuntu-archive-keyring.gpg` ). `Signed-By:` in a
`.sources` file, or `[signed-by=...]` in a `.list` line, restricts the source
to that key. For a third-party upstream, use the key its vendor publishes.
2026-08-10 09:34:56 +01:00
The upstream defaults to `http://deb.debian.org/debian` . To proxy a different APT repository (e.g. Ubuntu), set `upstream.debian` in the config file or `PROXY_UPSTREAM_DEBIAN` in the environment:
```yaml
upstream:
debian: "http://archive.ubuntu.com/ubuntu"
```
2026-09-16 10:15:59 -07:00
A release's security updates are served by a separate archive, so additional
archives are configured under `upstream.debian_repositories` and served at
`/debian/{name}/` :
feat(debian): serve additional APT archives at /debian/{name}/
A Debian release is served by more than one archive. Security updates live on
a different host than the main archive, so a single upstream.debian URL cannot
serve a complete suite set and -security suites are unreachable.
Add upstream.debian_repositories, a name-to-URL map served at /debian/{name}/,
modelled on upstream.apk. The field is additive: upstream.debian keeps serving
/debian/pool/... and /debian/dists/... with unchanged cache identities, so
existing deployments and their warm caches are unaffected, and the scalar
field and PROXY_UPSTREAM_DEBIAN are untouched.
Named repositories scope both caches by name, since the same filename can hold
different bytes in different archives. Metadata keys are hashed over name,
upstream URL, and path, as APKHandler does. Names are validated through
validateNamedUpstreams, and "pool" and "dists" are refused because they would
shadow the main archive's own paths.
An unconfigured first path segment stays a main-archive path rather than
returning 404 as the APK handler does: the main archive is unnamed and serves
paths of its own at the root.
2026-09-14 15:41:13 -07:00
```yaml
upstream:
debian: "http://deb.debian.org/debian"
debian_repositories:
security: "https://security.debian.org/debian-security"
```
```
deb http://localhost:8080/debian trixie main
deb http://localhost:8080/debian/security trixie-security main
```
`upstream.debian` keeps serving `/debian/pool/…` and `/debian/dists/…`
2026-09-16 10:15:59 -07:00
unchanged. See [Debian archives ](docs/configuration.md#debian-archives ).
feat(debian): serve additional APT archives at /debian/{name}/
A Debian release is served by more than one archive. Security updates live on
a different host than the main archive, so a single upstream.debian URL cannot
serve a complete suite set and -security suites are unreachable.
Add upstream.debian_repositories, a name-to-URL map served at /debian/{name}/,
modelled on upstream.apk. The field is additive: upstream.debian keeps serving
/debian/pool/... and /debian/dists/... with unchanged cache identities, so
existing deployments and their warm caches are unaffected, and the scalar
field and PROXY_UPSTREAM_DEBIAN are untouched.
Named repositories scope both caches by name, since the same filename can hold
different bytes in different archives. Metadata keys are hashed over name,
upstream URL, and path, as APKHandler does. Names are validated through
validateNamedUpstreams, and "pool" and "dists" are refused because they would
shadow the main archive's own paths.
An unconfigured first path segment stays a main-archive path rather than
returning 404 as the APK handler does: the main archive is unnamed and serves
paths of its own at the root.
2026-09-14 15:41:13 -07:00
2026-09-22 12:47:37 +05:30
The version denylist blocks recognized `.deb` downloads but leaves signed APT
indexes unchanged. To keep APT from selecting a denied version, configure
[client-side version pinning ](docs/configuration.md#apt-version-pinning ).
2026-01-29 19:35:15 +00:00
### RPM / Yum / DNF
Configure yum/dnf to use the proxy in `/etc/yum.repos.d/proxy.repo` :
```ini
[proxy-fedora]
name=Fedora via Proxy
baseurl=http://localhost:8080/rpm/releases/$releasever/Everything/$basearch/os/
enabled=1
gpgcheck=0
```
Then:
```bash
sudo dnf clean all
sudo dnf update
```
Add Alpine APK repository proxy support (#293)
* Add Alpine APK repository proxy support
- Serve named APK repositories at /apk/{repository}/ with the official
Alpine mirror as the default repository
- Cache v2 APKINDEX.tar.gz and v3 Packages.adb indexes and detached
signatures via the metadata cache, serving stored bytes unchanged so
apk signature verification keeps working
- Cache .apk packages in the shared artifact cache keyed by the full
repository path, since APK filenames do not include the architecture
- Add configurable upstream repositories via upstream.apk with
validation, plus dashboard registry instructions
- Add tests for index/signature byte fidelity, per-arch caching, cache
hits, offline reads, upstream authentication, and 404 handling
- Document apk usage in README, config example, and configuration docs
* Serve APK package HEAD requests without a body
Use the method-aware serveArtifact helper (as container.go does) so HEAD
responses carry Content-Length but omit the body; add a regression test.
* Drop doubled blank line in docs/configuration.md
---------
Co-authored-by: Andrew Nesbitt <andrewnez@gmail.com>
2026-09-02 13:36:00 +02:00
### Alpine / apk
Point `/etc/apk/repositories` at the proxy. The default repository name
`alpine` proxies the official mirror (`https://dl-cdn.alpinelinux.org/alpine` ):
```
http://localhost:8080/apk/alpine/v3.22/main
http://localhost:8080/apk/alpine/v3.22/community
```
Then:
```bash
apk update
```
Repository indexes (v2 `APKINDEX.tar.gz` and v3 `Packages.adb` ), detached
signatures, and packages are served byte-for-byte unchanged, so apk's normal
signature verification keeps working. Indexes use the metadata cache
(`metadata_ttl` , stale fallback); `.apk` packages are stored in the shared
artifact cache and remain available when the upstream is unreachable.
To proxy other mirrors or private repositories, configure named upstreams
under `upstream.apk` (this replaces the built-in default; re-add `alpine` if
you still want it):
```yaml
upstream:
apk:
alpine: "https://dl-cdn.alpinelinux.org/alpine"
private: "https://apk.example.com"
```
```
http://localhost:8080/apk/private
```
apk appends the architecture and index filename to each repository line
itself.
2026-10-11 23:41:16 +02:00
### Arch Linux / pacman
Put the proxy at the top of `/etc/pacman.d/mirrorlist` . The default repository
name `archlinux` proxies the official GeoIP mirror
(`https://geo.mirror.pkgbuild.com` ):
```
Server = http://localhost:8080/arch/archlinux/$repo/os/$arch
```
Then:
```bash
pacman -Syu
```
Repository databases (`core.db` , `core.files` and their `.tar.*` forms),
database signatures, packages and package signatures are served byte-for-byte
unchanged, so pacman's signature checks keep working. Databases use the
2026-10-12 10:59:22 +02:00
metadata cache (`metadata_ttl` , stale fallback). Each database refresh also
fetches its signature and stores it for exactly that database, so a signature
is only ever served with the database it signs. Enable `cache_metadata` (or set `PROXY_CACHE_METADATA=true` ) so
2026-10-11 23:51:43 +02:00
`pacman -Sy` keeps working while the upstream is down; without it databases are
fetched fresh on every request. Packages and their `.sig` files are stored in the shared artifact
2026-10-11 23:41:16 +02:00
cache and remain available when the upstream is unreachable.
The proxy only starts sending a package once it has the whole file, and pacman
gives up on a download that sends nothing for 10 seconds. The fetch keeps
running and the next try is served from the cache. On a slow upstream, set
`DisableDownloadTimeout` in `/etc/pacman.conf` to avoid the failed first try.
`extra.files` is about 50 MB and is read into memory on each `pacman -F`
request. Raise `metadata_max_size` (100 MB by default) for larger custom
repositories.
A custom repository that rebuilds a package under the same version leaves the
old package and signature in the cache. Bump `pkgrel` instead, or remove the
cached version.
#### Arch Linux ARM
Arch Linux ARM has no built-in repository. Add one under `upstream.arch` , using a
regional mirror from <https://archlinuxarm.org/about/mirrors> that serves HTTPS:
```yaml
upstream:
arch:
archlinux: "https://geo.mirror.pkgbuild.com"
archlinuxarm: "https://<region>.mirror.archlinuxarm.org"
```
Setting `upstream.arch` replaces the built-in default, so keep `archlinux` in the map
if you also proxy x86_64 Arch.
Arch Linux ARM mirrors use `$arch/$repo` instead of `$repo/os/$arch` . In
`/etc/pacman.d/mirrorlist` :
```
Server = http://localhost:8080/arch/archlinuxarm/$arch/$repo
```
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.
Add generic HTTP download proxy for GitHub release assets (mise/aqua) (#302)
* Add generic HTTP download proxy for GitHub release assets
Adds a /generic/{name}/ route backed by a new upstream.generic named-upstream
map, so tools that download from fixed URL shapes (mise's aqua backend
fetching GitHub release assets, and its tag lookups on api.github.com) can be
pointed at the proxy with client-side URL rewriting. Only configured
upstreams are reachable, so this is not an open HTTP proxy.
Paths shaped like {owner}/{repo}/releases/download/{tag}/{asset} are
version-pinned and go through the artifact cache: fetched once, hashed,
served without revalidation, and still served when the upstream is down.
Every other path goes through the metadata cache with the client's Accept
header and query string replayed, so API responses are fresh within
metadata_ttl, revalidated after that, and served stale when the upstream
fails or rate-limits the request.
Tests cover path classification, unknown upstreams and traversal, cache
hits with the upstream down, HEAD, 404 pass-through, Accept and query
forwarding, stale-on-429, cache isolation between upstreams, and that an
upstream token scoped to the release host is not sent to the object store
it redirects to.
Closes #183.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDjDeq27CKzEP2o3GWBY7F
* Use fixed Accept header for generic metadata
---------
Co-authored-by: Giles Westwood <giles@gileswestwood.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 16:44:54 +01:00
### GitHub Releases / mise (aqua backend)
Configure named generic upstreams:
```yaml
upstream:
generic:
github: "https://github.com"
github-api: "https://api.github.com"
```
Then rewrite GitHub URLs in mise's settings (`~/.config/mise/config.toml` , mise ≥ 2025.9.3):
```toml
[settings.url_replacements]
"regex:^https://github\\.com/([^/]+)/([^/]+)/releases/download/(.+)" = "http://localhost:8080/generic/github/$1/$2/releases/download/$3"
"regex:^https://api\\.github\\.com/(.*)" = "http://localhost:8080/generic/github-api/$1"
```
Release assets are cached permanently after the first download and keep
installing while GitHub is down. Tag lookups through `api.github.com` are
cached for `metadata_ttl` and served stale during an outage or rate limit.
Commit a `mise.lock` and install with `mise install --locked` so pinned
installs need no API call at all. Add a bearer token for `https://api.github.com`
under `upstream.auth` if the fleet exceeds GitHub's anonymous rate limit.
2026-10-12 15:39:23 +01:00
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
(see [configuration ](docs/configuration.md )):
2026-10-12 09:12:16 +01:00
```yaml
upstream:
generic:
apache: "https://archive.apache.org"
2026-10-12 15:39:23 +01:00
nodejs: "https://nodejs.org"
golang: "https://dl.google.com/go"
2026-10-12 09:12:16 +01:00
```
```toml
2026-10-12 15:39:23 +01:00
[settings]
node.mirror_url = "http://localhost:8080/generic/nodejs/dist/"
go.download_mirror = "http://localhost:8080/generic/golang"
2026-10-12 09:12:16 +01:00
[settings.url_replacements]
"https://archive.apache.org/" = "http://localhost:8080/generic/apache/"
```
2026-10-12 15:39:23 +01:00
Other download hosts need their version-pinned paths declared in
`upstream.generic_artifacts` .
2026-01-20 21:52:44 +00:00
## Configuration
The proxy can be configured via:
2026-05-22 18:05:20 +02:00
2026-01-20 21:52:44 +00:00
1. Command line flags (highest priority)
2. Environment variables
3. Configuration file (YAML or JSON)
### Command Line Flags
```
2026-03-18 11:15:45 +00:00
-config string Path to configuration file
-listen string Address to listen on (default ":8080")
-base-url string Public URL of this proxy (default "http://localhost:8080")
2026-08-29 11:23:07 +01:00
-storage-url string Storage URL (file://, s3://, gs://, azblob://)
2026-03-18 11:15:45 +00:00
-storage-path string Path to artifact storage directory (deprecated, use -storage-url)
-database-driver string Database driver: sqlite or postgres (default "sqlite")
-database-path string Path to SQLite database file (default "./cache/proxy.db")
-database-url string PostgreSQL connection URL
-log-level string Log level: debug, info, warn, error (default "info")
-log-format string Log format: text, json (default "text")
2026-08-16 18:07:39 +01:00
-access-log string Path to the JSONL access log
2026-03-18 11:15:45 +00:00
-version Print version and exit
2026-01-20 21:52:44 +00:00
```
### Environment Variables
```bash
PROXY_LISTEN=:8080
PROXY_BASE_URL=http://localhost:8080
2026-06-07 16:12:43 +01:00
PROXY_UI_URL=http://localhost:8080 # Optional; defaults to PROXY_BASE_URL
2026-03-18 11:15:45 +00:00
PROXY_STORAGE_URL=file:///var/cache/proxy/artifacts
PROXY_DATABASE_DRIVER=sqlite
2026-01-20 21:52:44 +00:00
PROXY_DATABASE_PATH=./cache/proxy.db
2026-03-18 11:15:45 +00:00
PROXY_DATABASE_URL=postgres://user:pass@localhost/proxy ?sslmode=disable
2026-01-20 21:52:44 +00:00
PROXY_LOG_LEVEL=info
PROXY_LOG_FORMAT=text
2026-08-16 18:07:39 +01:00
PROXY_ACCESS_LOG_PATH=/var/log/proxy/access.jsonl
2026-09-02 13:06:34 +01:00
PROXY_UPSTREAM_SWIFT=https://tuist.dev/api/registry/swift
2026-01-20 21:52:44 +00:00
```
### Configuration File
```yaml
listen: ":8080"
base_url: "http://localhost:8080"
storage:
2026-03-18 11:15:45 +00:00
url: "file:///var/cache/proxy/artifacts"
2026-01-20 21:52:44 +00:00
max_size: "10GB" # Optional: evict LRU when exceeded
Add age-based retention for cached artifacts (#428)
* Add age-based retention for cached artifacts
Cached artifacts stayed forever unless storage.max_size pushed them out.
storage.retention adds a default, per-ecosystem and per-package duration
after which an artifact nobody downloaded gets evicted.
Ecosystems opt in one at a time: a handler registers with
retention.Default, and until it does, configuring that ecosystem (or one
of its packages) fails validation, and the default doesn't touch it. This
commit registers none, so nothing changes yet. Startup logs which
ecosystems the default covers and which it doesn't.
An artifact counts as expired when both its fetch time and its last
access are older than the cutoff. Checking only the last access would
evict a refetched artifact again right away, since clearing a record
keeps its old access time and a cache miss doesn't record a hit.
The sweep scans artifacts in id windows, so no single query holds the
SQLite connection for long, and checks each row against its own rule.
Expired records are cleared and their files go through pending_deletes
instead of being deleted directly, because a buffered hit can make an
artifact that's being served right now look expired. Buffered hits are
flushed before each sweep, and one sweep clears at most 10,000
artifacts.
Since a big first sweep can queue a lot, reclaim now keeps working
through due entries for up to 30 seconds per tick instead of stopping
after 100, and pending_deletes gets an index on queued_at.
Also:
- proxy_artifacts_evicted_total{reason, ecosystem} counts LRU and
retention evictions.
- gradle.build_cache.max_age accepts "7d", as its comment always said.
Refs #306
* Document storage retention
Describes the new storage.retention block, the order rules apply in,
that the default only covers ecosystems that support retention yet, and
what to expect from the first sweep over an old cache. Also lists the
new eviction counter and notes that gradle max_age takes days.
Refs #306
* Fix retention timing on Postgres and tighten the sweep
On Postgres, fetched_at and last_accessed_at are TIMESTAMP columns
without a zone. They hold the local time the proxy wrote, but lib/pq
reads them back labelled UTC. East of UTC that makes every artifact look
hours younger than it is, so the sweep evicted late: two hours in
Berlin, nine in Tokyo. The candidates' times are now read back as local
time, and the database tests run on both SQLite and Postgres, with one
that sets the local zone to Asia/Tokyo.
A few smaller things:
- When a delete fails, reclaim stops for the rest of the minute instead
of trying every queued path. Otherwise a storage outage turns into
thirty seconds of failed deletes and warnings every minute.
- If the buffered hits can't be written, the sweep skips that round.
Running on stale access times could clear something that is being
downloaded right now.
- DefaultCanonical only accepts a package key when rebuilding it gives
the same PURL. For alpine and deb it would otherwise produce keys
that never match.
- sweep_interval has to be at least a minute.
- New tests cover the eviction counter and an artifact that gets a hit
between the scan and the clear.
- The docs no longer say nothing changes without retention, and they
point out that the example's ecosystem and package rules only pass
validation once those ecosystems support retention.
Refs #306
* Reject qualifiers and subpaths in retention package keys
A rule names a whole package. Qualifiers like ?type=pom were dropped on
the way to the stored PURL and the rule quietly covered every file of the
package. Now such a key fails validation.
Refs #306
* Say when a package key doesn't match how packages are stored
A package rule for an ecosystem that supports retention, written in a
form its packages aren't stored under (pkg:npm/babel/core without the
@), failed with "retention is not supported for npm packages". Once the
ecosystem is enabled that is no longer true. Such a key now fails with
"does not match how npm packages are stored" and points to the package
rule forms in the docs. Keys for ecosystems without retention support
keep the old message.
Refs #306
* Note that re-running the mirror keeps packages cached
A mirror run over a package that is still cached goes through the normal
cache lookup and records a download, so the package's age starts over.
The docs only said that mirrored packages age from the time they were
mirrored.
Refs #306
* Say when an ecosystem takes no package rules
Some ecosystems will support retention without package rules, because nothing in their stored records names a package the way a PURL would (conan, helm). A package key for one of them used to fail with "does not match how conan packages are stored; see the package rule forms", which sends people looking for a form that doesn't exist. It now says package rules aren't supported there and points to the ecosystem rule.
Refs #306
* Use helm as the example of an ecosystem without package rules
Conan gets package rules once its handler stores archives under the recipe name, so the config test that shows the refusal now uses helm, whose cache records name the repository, not the chart.
Refs #306
* Say that each cached file ages on its own
The retention docs said each artifact, each version of a package, ages on its own. A version can have several cached files (a jar and its pom, several wheels, Conan sources and binaries), and each of them ages separately.
Refs #306
* Clear an expired artifact and queue its file in one transaction
The sweep cleared the record and queued the file for deletion as two separate writes. If queueing failed, or the process stopped in between, the file stayed in storage with no record pointing at it and no queue entry, and no later sweep would find it again. Both writes now share a transaction, so a failed queue rolls the clear back and the next sweep retries.
Refs #306
* Show artifact evictions on the analytics page
proxy_artifacts_evicted_total had no place on /ui/analytics. The Runtime card now lists evicted artifacts by reason and ecosystem, without the failure tint, and the coverage test records an eviction so it doesn't depend on test order.
Refs #306
* Store artifact times in UTC
fetched_at and last_accessed_at are now written in UTC, and the retention cutoffs are compared in UTC, the same convention #434 uses for the metadata cache. lib/pq sends a time with its offset and a Postgres TIMESTAMP keeps only the wall clock, so UTC on write is what reads back correctly. That replaces reinterpreting the times as local on read.
Rows written before this still hold local time. East of UTC they look younger by the offset until they are rewritten, so retention evicts them a few hours late once; west of UTC that much early.
Refs #306
---------
Co-authored-by: t <t@t>
2026-10-12 10:06:01 +02:00
retention:
2026-10-19 15:43:57 +08:00
default: "30d" # Optional: evict unused artifacts after 30 days; excludes generic
2026-01-20 21:52:44 +00:00
database:
2026-03-18 11:15:45 +00:00
driver: "sqlite"
2026-01-20 21:52:44 +00:00
path: "/var/lib/proxy/cache.db"
log:
level: "info"
format: "text"
2026-08-16 18:07:39 +01:00
access_log:
path: "/var/log/proxy/access.jsonl" # Optional JSONL activity log
2026-01-20 21:52:44 +00:00
# Optional: override upstream URLs
upstream:
npm: "https://registry.npmjs.org"
cargo: "https://index.crates.io"
2026-09-02 13:06:34 +01:00
swift: "https://tuist.dev/api/registry/swift"
2026-03-04 19:00:31 +00:00
2026-03-04 19:07:53 +00:00
# Optional: version cooldown (see above)
2026-03-04 19:00:31 +00:00
cooldown:
default: "3d"
2026-01-20 21:52:44 +00:00
```
2026-08-29 11:19:54 +01:00
See the [configuration reference ](docs/configuration.md#upstream-registries ) for every upstream key, environment variable, and default URL.
2026-01-20 21:52:44 +00:00
Run with config file:
```bash
./proxy -config /etc/proxy/config.yaml
```
2026-03-18 11:15:45 +00:00
### 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:
```yaml
database:
driver: "postgres"
url: "postgres://user:password@localhost:5432/proxy ?sslmode=disable"
```
Or via environment variables:
```bash
PROXY_DATABASE_DRIVER=postgres
PROXY_DATABASE_URL=postgres://user:password@localhost:5432/proxy ?sslmode=disable
```
The proxy creates tables automatically on first run.
### S3 Storage
The proxy can store cached artifacts in S3 or any S3-compatible service (MinIO, R2, etc.) instead of the local filesystem.
```yaml
storage:
url: "s3://my-bucket-name?region=us-east-1"
```
For S3-compatible services like MinIO:
```yaml
storage:
url: "s3://my-bucket?endpoint=http://localhost:9000&disableSSL=true&s3ForcePathStyle=true"
```
Set credentials via standard AWS environment variables (`AWS_ACCESS_KEY_ID` , `AWS_SECRET_ACCESS_KEY` , `AWS_REGION` ).
2026-08-29 11:23:07 +01:00
### Google Cloud Storage
The proxy can store cached artifacts in a GCS bucket using the `gs://` URL scheme.
```yaml
storage:
url: "gs://my-bucket-name"
```
Authentication uses [Application Default Credentials ](https://docs.cloud.google.com/docs/authentication/application-default-credentials ), which means no credentials need to be embedded in the config or environment. Supported sources, in order:
- **GKE Workload Identity** — bind the Kubernetes service account running the proxy to a Google service account that has `roles/storage.objectAdmin` on the bucket. The proxy will use the workload's token automatically.
- **Attached service account** on GCE, Cloud Run, Cloud Functions, etc.
- **`GOOGLE_APPLICATION_CREDENTIALS` ** environment variable pointing at a service account JSON key file.
- **`gcloud auth application-default login` ** for local development.
#### GKE Workload Identity setup
```bash
# 1. Create a Google service account
gcloud iam service-accounts create git-pkgs-proxy \
--project=PROJECT_ID
# 2. Grant it access to the bucket
gsutil iam ch \
serviceAccount:git-pkgs-proxy@PROJECT_ID .iam.gserviceaccount.com:objectAdmin \
gs://my-bucket-name
# 3. Bind the Kubernetes service account to it
gcloud iam service-accounts add-iam-policy-binding \
git-pkgs-proxy@PROJECT_ID .iam.gserviceaccount.com \
--role=roles/iam.workloadIdentityUser \
--member="serviceAccount:PROJECT_ID.svc.id.goog[NAMESPACE/KSA_NAME]"
# 4. Annotate the Kubernetes service account
kubectl annotate serviceaccount KSA_NAME \
--namespace=NAMESPACE \
iam.gke.io/gcp-service-account=git-pkgs-proxy@PROJECT_ID .iam.gserviceaccount.com
```
#### Direct serve (signed URLs) with Workload Identity
When `direct_serve: true` is enabled, the proxy issues HTTP 302 redirects to presigned GCS URLs. Workload Identity provides no private key, so the GCS backend calls the [IAM Credentials `signBlob` API ](https://docs.cloud.google.com/iam/docs/reference/credentials/rest/v1/projects.serviceAccounts/signBlob ). Grant the service account the token-creator role on itself:
```bash
gcloud iam service-accounts add-iam-policy-binding \
git-pkgs-proxy@PROJECT_ID .iam.gserviceaccount.com \
--role=roles/iam.serviceAccountTokenCreator \
--member="serviceAccount:git-pkgs-proxy@PROJECT_ID .iam.gserviceaccount.com"
```
2026-01-20 21:52:44 +00:00
## CLI Commands
### serve (default)
Start the proxy server. This is the default command if none is specified.
```bash
proxy serve [flags]
proxy [flags] # same as 'proxy serve'
```
2026-03-19 21:06:02 +00:00
### mirror
Pre-populate the cache from PURLs, SBOM files, or entire registries. Useful for ensuring offline availability or warming the cache before deployments.
```bash
# Mirror specific package versions
proxy mirror pkg:npm/lodash@4 .17.21 pkg:cargo/serde@1 .0.0
# Mirror all versions of a package
proxy mirror pkg:npm/lodash
# Mirror from a CycloneDX or SPDX SBOM
proxy mirror --sbom sbom.cdx.json
# Preview what would be mirrored
proxy mirror --dry-run pkg:npm/lodash
# Control parallelism
proxy mirror --concurrency 8 pkg:npm/lodash@4 .17.21
2026-10-12 00:20:10 +02:00
# Mirror a container image for one platform
proxy mirror --platform linux/amd64 pkg:docker/library/alpine@3 .20
2026-03-19 21:06:02 +00:00
```
2026-10-19 00:55:13 +05:30
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.
2026-03-19 21:06:02 +00:00
2026-10-19 12:32:05 +05:30
With `scanning.enabled` , the mirror command refuses every package and container image so nothing is cached unscanned. Each one is reported as failed, so the command exits with status 1. Scanners fetch the staged bytes from the running server, which this command does not start. Use the mirror API below instead, since its jobs run inside the server and go through the pre-cache scan. `--dry-run` still lists what would be mirrored.
2026-03-19 21:06:02 +00:00
A mirror API is also available when the server is running:
```bash
# Start a mirror job
curl -X POST http://localhost:8080/api/mirror \
-H "Content-Type: application/json" \
-d '{"purls": ["pkg:npm/lodash@4 .17.21"]}'
2026-09-02 16:56:29 +05:30
# Start a mirror job from an inline CycloneDX or SPDX JSON SBOM
curl -X POST http://localhost:8080/api/mirror \
-H "Content-Type: application/json" \
-d '{"sbom":{"bomFormat":"CycloneDX","components":[{"purl":"pkg:npm/lodash@4 .17.21"}]}}'
2026-03-19 21:06:02 +00:00
# Check job status
curl http://localhost:8080/api/mirror/mirror-1
# Cancel a running job
curl -X DELETE http://localhost:8080/api/mirror/mirror-1
```
2026-10-13 15:20:57 +02:00
### hosted
2026-10-13 13:11:49 +02:00
Push package archives to a running proxy with `hosted.enabled` , list them and delete them. See [Hosted Packages ](docs/configuration.md#hosted-packages ).
```bash
export PROXY_URL=https://proxy.example.com PROXY_HOSTED_TOKEN=...
2026-10-13 15:20:57 +02:00
proxy hosted push npm request 2.88.3 ./request-2.88.3.tgz
proxy hosted push composer monolog/monolog 3.5.0 ./monolog-3.5.0.zip
proxy hosted list npm request
proxy hosted delete npm request 2.88.3
2026-10-13 13:11:49 +02:00
```
2026-01-20 21:52:44 +00:00
### stats
Show cache statistics without running the server.
```bash
# Text output
proxy stats
# JSON output
proxy stats -json
# Custom database path
2026-03-18 11:15:45 +00:00
proxy stats -database-path /var/lib/proxy/cache.db
# With PostgreSQL
proxy stats -database-driver postgres -database-url postgres://user:pass@localhost/proxy
2026-01-20 21:52:44 +00:00
# Show top 20 most popular packages
proxy stats -popular 20
```
Example output:
```
Cache Statistics
================
Packages: 45
Versions: 128
Artifacts: 128
Total size: 892.4 MB
Total hits: 1547
Packages by ecosystem:
npm 32
cargo 13
Most popular packages:
1. npm/lodash (342 hits, 24.7 KB)
2. npm/react (198 hits, 89.3 KB)
3. cargo/serde (156 hits, 234.1 KB)
Recently cached:
npm/express@4 .18.2 (2024-01-15 14:32, 54.2 KB)
cargo/tokio@1 .35.0 (2024-01-15 14:28, 412.8 KB)
```
## API Endpoints
2026-01-29 19:35:15 +00:00
### Registry Protocols
2026-01-20 21:52:44 +00:00
| Endpoint | Description |
|----------|-------------|
2026-03-12 12:18:27 +00:00
| `GET /` | Dashboard (web UI) |
2026-09-02 13:52:32 +02:00
| `GET /health` | Health check and upstream circuit breaker state (JSON; HTTP 200 healthy, 503 unhealthy) |
2026-01-20 21:52:44 +00:00
| `GET /stats` | Cache statistics (JSON) |
2026-03-12 12:18:27 +00:00
| `GET /metrics` | Prometheus metrics |
2026-01-20 21:52:44 +00:00
| `GET /npm/*` | npm registry protocol |
2026-09-30 18:45:08 +02:00
| `POST /npm/-/npm/v1/security/*` | npm/pnpm/Yarn audit endpoints, passed through to upstream |
2026-10-01 10:09:33 +02:00
| `GET /npm/-/npm/v1/keys` | npm registry signing keys, passed through to upstream |
2026-01-20 21:52:44 +00:00
| `GET /cargo/*` | Cargo sparse index protocol |
| `GET /gem/*` | RubyGems protocol |
| `GET /go/*` | Go module proxy protocol |
| `GET /hex/*` | Hex.pm protocol |
| `GET /pub/*` | pub.dev protocol |
| `GET /pypi/*` | PyPI simple/JSON API |
| `GET /maven/*` | Maven repository protocol |
| `GET /nuget/*` | NuGet V3 API |
| `GET /composer/*` | Composer/Packagist protocol |
| `GET /conan/*` | Conan C/C++ protocol |
2026-10-19 12:29:39 +05:30
| `GET /chef/*` | Chef Supermarket protocol (universe, cookbook API and downloads) |
2026-01-20 21:52:44 +00:00
| `GET /conda/*` | Conda/Anaconda protocol |
| `GET /cran/*` | CRAN (R) protocol |
2026-05-13 06:46:35 +01:00
| `GET /julia/*` | Julia Pkg server protocol |
2026-09-02 13:06:34 +01:00
| `GET /swift/*` | Swift Package Registry v1 protocol |
2026-08-16 22:42:55 +05:30
| `GET /helm/{repository}/*` | HTTP Helm chart repository protocol |
2026-09-03 16:59:12 +01:00
| `GET /homebrew/*` | Homebrew JSON API |
2026-01-29 19:35:15 +00:00
| `GET /v2/*` | OCI/Docker registry protocol |
2026-09-03 16:59:12 +01:00
| `GET /v2/homebrew/core/*` | Homebrew core bottle manifests and blobs from GHCR |
Add Alpine APK repository proxy support (#293)
* Add Alpine APK repository proxy support
- Serve named APK repositories at /apk/{repository}/ with the official
Alpine mirror as the default repository
- Cache v2 APKINDEX.tar.gz and v3 Packages.adb indexes and detached
signatures via the metadata cache, serving stored bytes unchanged so
apk signature verification keeps working
- Cache .apk packages in the shared artifact cache keyed by the full
repository path, since APK filenames do not include the architecture
- Add configurable upstream repositories via upstream.apk with
validation, plus dashboard registry instructions
- Add tests for index/signature byte fidelity, per-arch caching, cache
hits, offline reads, upstream authentication, and 404 handling
- Document apk usage in README, config example, and configuration docs
* Serve APK package HEAD requests without a body
Use the method-aware serveArtifact helper (as container.go does) so HEAD
responses carry Content-Length but omit the body; add a regression test.
* Drop doubled blank line in docs/configuration.md
---------
Co-authored-by: Andrew Nesbitt <andrewnez@gmail.com>
2026-09-02 13:36:00 +02:00
| `GET /apk/{repository}/*` | Alpine APK repository protocol |
2026-10-11 23:41:16 +02:00
| `GET /arch/{repository}/*` | Arch Linux pacman repository protocol |
Add generic HTTP download proxy for GitHub release assets (mise/aqua) (#302)
* Add generic HTTP download proxy for GitHub release assets
Adds a /generic/{name}/ route backed by a new upstream.generic named-upstream
map, so tools that download from fixed URL shapes (mise's aqua backend
fetching GitHub release assets, and its tag lookups on api.github.com) can be
pointed at the proxy with client-side URL rewriting. Only configured
upstreams are reachable, so this is not an open HTTP proxy.
Paths shaped like {owner}/{repo}/releases/download/{tag}/{asset} are
version-pinned and go through the artifact cache: fetched once, hashed,
served without revalidation, and still served when the upstream is down.
Every other path goes through the metadata cache with the client's Accept
header and query string replayed, so API responses are fresh within
metadata_ttl, revalidated after that, and served stale when the upstream
fails or rate-limits the request.
Tests cover path classification, unknown upstreams and traversal, cache
hits with the upstream down, HEAD, 404 pass-through, Accept and query
forwarding, stale-on-429, cache isolation between upstreams, and that an
upstream token scoped to the release host is not sent to the object store
it redirects to.
Closes #183.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDjDeq27CKzEP2o3GWBY7F
* Use fixed Accept header for generic metadata
---------
Co-authored-by: Giles Westwood <giles@gileswestwood.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 16:44:54 +01:00
| `GET /generic/{name}/*` | Generic HTTP download proxy (GitHub release assets, mise/aqua) |
feat(debian): serve additional APT archives at /debian/{name}/
A Debian release is served by more than one archive. Security updates live on
a different host than the main archive, so a single upstream.debian URL cannot
serve a complete suite set and -security suites are unreachable.
Add upstream.debian_repositories, a name-to-URL map served at /debian/{name}/,
modelled on upstream.apk. The field is additive: upstream.debian keeps serving
/debian/pool/... and /debian/dists/... with unchanged cache identities, so
existing deployments and their warm caches are unaffected, and the scalar
field and PROXY_UPSTREAM_DEBIAN are untouched.
Named repositories scope both caches by name, since the same filename can hold
different bytes in different archives. Metadata keys are hashed over name,
upstream URL, and path, as APKHandler does. Names are validated through
validateNamedUpstreams, and "pool" and "dists" are refused because they would
shadow the main archive's own paths.
An unconfigured first path segment stays a main-archive path rather than
returning 404 as the APK handler does: the main archive is unnamed and serves
paths of its own at the root.
2026-09-14 15:41:13 -07:00
| `GET /debian/*` | Debian/APT repository protocol (main archive) |
| `GET /debian/{repository}/*` | Debian/APT repository protocol (named archive, e.g. security) |
2026-01-29 19:35:15 +00:00
| `GET /rpm/*` | RPM/Yum repository protocol |
2026-03-19 21:06:02 +00:00
### Mirror API
| Endpoint | Description |
|----------|-------------|
2026-09-02 16:56:29 +05:30
| `POST /api/mirror` | Start a mirror job (JSON body with `purls` or an inline `sbom` ) |
2026-03-19 21:06:02 +00:00
| `GET /api/mirror/{id}` | Get job status and progress |
| `DELETE /api/mirror/{id}` | Cancel a running job |
2026-10-13 13:11:49 +02:00
### Hosted API
Registered when `hosted.enabled` is set. Pushing and deleting require `Authorization: Bearer <hosted.token>` .
| Endpoint | Description |
|----------|-------------|
| `PUT /api/hosted/{ecosystem}/{name}/{version}` | Push the request body as the archive for a version (npm and composer) |
| `GET /api/hosted/{ecosystem}/{name}` | List the archives pushed for a package |
| `DELETE /api/hosted/{ecosystem}/{name}/{version}` | Delete a pushed version |
2026-10-20 11:35:08 +01:00
### Cache API
Registered when `cache_delete` is set. It has no authentication of its own.
| Endpoint | Description |
|----------|-------------|
| `DELETE /api/cache/{ecosystem}/{name}/{version}` | Delete a version's cached files, or only `?filename=` |
2026-01-29 19:35:15 +00:00
### Enrichment API
The proxy provides REST endpoints for package metadata enrichment, vulnerability scanning, and outdated detection.
| Endpoint | Description |
|----------|-------------|
| `GET /api/package/{ecosystem}/{name}` | Get package metadata |
| `GET /api/package/{ecosystem}/{name}/{version}` | Get version metadata with vulnerabilities |
| `GET /api/vulns/{ecosystem}/{name}` | Get all vulnerabilities for a package |
| `GET /api/vulns/{ecosystem}/{name}/{version}` | Get vulnerabilities for a specific version |
| `POST /api/outdated` | Check multiple packages for outdated versions |
| `POST /api/bulk` | Bulk package metadata lookup |
#### Get Package Metadata
```bash
curl http://localhost:8080/api/package/npm/lodash
```
Response:
```json
{
"ecosystem": "npm",
"name": "lodash",
"latest_version": "4.17.21",
"license": "MIT",
"license_category": "permissive",
"description": "Lodash modular utilities",
"homepage": "https://lodash.com/",
"repository": "https://github.com/lodash/lodash",
"registry_url": "https://registry.npmjs.org"
}
```
#### Get Version with Vulnerabilities
```bash
curl http://localhost:8080/api/package/npm/lodash/4.17.0
```
Response:
```json
{
"package": {
"ecosystem": "npm",
"name": "lodash",
"latest_version": "4.17.21",
"license": "MIT",
"license_category": "permissive"
},
"version": {
"ecosystem": "npm",
"name": "lodash",
"version": "4.17.0",
"license": "MIT",
"published_at": "2016-06-17T03:59:56Z",
"yanked": false,
"is_outdated": true
},
"vulnerabilities": [
{
"id": "GHSA-p6mc-m468-83gw",
"summary": "Prototype Pollution in lodash",
"severity": "HIGH",
"cvss_score": 7.4,
"fixed_version": "4.17.12"
}
],
"is_outdated": true,
"license_category": "permissive"
}
```
#### Check Outdated Packages
```bash
curl -X POST http://localhost:8080/api/outdated \
-H "Content-Type: application/json" \
-d '{
"packages": [
{"ecosystem": "npm", "name": "lodash", "version": "4.17.0"},
{"ecosystem": "pypi", "name": "requests", "version": "2.25.0"}
]
}'
```
Response:
```json
{
"results": [
{
"ecosystem": "npm",
"name": "lodash",
"version": "4.17.0",
"latest_version": "4.17.21",
"is_outdated": true
},
{
"ecosystem": "pypi",
"name": "requests",
"version": "2.25.0",
"latest_version": "2.31.0",
"is_outdated": true
}
]
}
```
#### Bulk Package Lookup
```bash
curl -X POST http://localhost:8080/api/bulk \
-H "Content-Type: application/json" \
-d '{
"purls": [
"pkg:npm/lodash@4 .17.21",
"pkg:pypi/requests@2 .28.0"
]
}'
```
Response:
```json
{
"packages": {
"pkg:npm/lodash": {
"ecosystem": "npm",
"name": "lodash",
"latest_version": "4.17.21",
"license": "MIT",
"license_category": "permissive"
},
"pkg:pypi/requests": {
"ecosystem": "pypi",
"name": "requests",
"latest_version": "2.31.0",
"license": "Apache-2.0",
"license_category": "permissive"
}
}
}
```
2026-01-20 21:52:44 +00:00
### Stats Response (HTTP endpoint)
```json
{
"cached_artifacts": 142,
"total_size_bytes": 523456789,
"total_size": "499.2 MB",
2026-04-03 14:06:51 +01:00
"storage_url": "file:///path/to/cache/artifacts",
2026-01-20 21:52:44 +00:00
"database_path": "./cache/proxy.db"
}
```
## How It Works
1. Package manager requests package metadata from the proxy
2. Proxy fetches metadata from upstream, rewrites artifact URLs to point at proxy
3. Package manager requests artifact (tarball, crate, etc.)
4. Proxy checks local cache:
- **Cache hit**: Serve from local storage
- **Cache miss**: Fetch from upstream, store locally, serve to client
5. Subsequent requests for the same artifact are served from cache
```
┌─────────────┐ ┌─────────┐ ┌──────────┐
│ npm/cargo │────▶│ proxy │────▶│ upstream │
│ client │◀────│ │◀────│ registry │
└─────────────┘ └─────────┘ └──────────┘
│
▼
┌─────────┐
│ cache │
│ storage │
└─────────┘
```
2026-03-12 12:18:27 +00:00
## Web Interface
2026-06-07 15:47:29 +01:00
The proxy serves a web UI under `/ui` . No separate frontend build is needed -- templates and assets are embedded in the binary. `GET /` redirects to `/ui/` . The UI is mounted under its own prefix so a reverse proxy can apply different access rules to it than to the package endpoints (for example, requiring auth for `PathPrefix(/ui)` while leaving `/npm` , `/pypi` etc. open to build machines).
2026-03-12 12:18:27 +00:00
2026-06-07 15:47:29 +01:00
- **Dashboard** (`/ui/` ) -- cache stats, popular packages, recently cached artifacts, and vulnerability overview.
2026-10-01 14:32:20 +02:00
- **Analytics** (`/ui/analytics` ) -- accumulated download size as a ring broken down by ecosystem with the total in the middle, the cache size, artifact, package and version counts, a per-ecosystem table, the vulnerability overview, and a Runtime card mirroring every counter `/metrics` exposes. See [Analytics ](#analytics ).
2026-06-07 15:47:29 +01:00
- **Install guide** (`/ui/install` ) -- per-ecosystem configuration instructions, so you don't have to look them up here.
- **Package browser** (`/ui/packages` ) -- browse all cached packages with filtering by ecosystem and sorting by hits, size, name, or vulnerability count.
- **Search** (`/ui/search?q=...` ) -- search cached packages by name.
- **Package detail** (`/ui/package/{ecosystem}/{name}` ) -- metadata, license, vulnerabilities, and version list for a package. You can select two versions to compare.
- **Version detail** (`/ui/package/{ecosystem}/{name}/{version}` ) -- per-version metadata, integrity hash, artifact cache status, and hit counts.
- **Source browser** (`/ui/package/{ecosystem}/{name}/{version}/browse` ) -- browse files inside cached archives with syntax highlighting for text files and image previews.
- **Version diff** (`/ui/package/{ecosystem}/{name}/compare/{v1}...{v2}` ) -- side-by-side diff of two cached versions showing added, removed, and changed files.
2026-03-12 12:18:27 +00:00
## Monitoring
The proxy exposes Prometheus metrics at `GET /metrics` . All metric names are prefixed with `proxy_` .
| Metric | Type | Labels | Description |
|--------|------|--------|-------------|
2026-08-16 18:12:03 +01:00
| `proxy_requests_total` | counter | `ecosystem` , `status` | Proxy responses by package ecosystem and HTTP status |
| `proxy_request_duration_seconds` | histogram | `ecosystem` , `status` | Proxy request duration |
2026-10-20 15:55:33 +05:30
| `proxy_cache_hits_total` | counter | `ecosystem` , `kind` | Cache hits. `kind` is `artifact` or `metadata` . |
| `proxy_cache_misses_total` | counter | `ecosystem` , `kind` | Cache misses. `kind` is `artifact` or `metadata` ; metadata misses are counted only while `cache_metadata` is on. |
2026-03-12 12:18:27 +00:00
| `proxy_cache_size_bytes` | gauge | | Total size of cached artifacts |
| `proxy_cached_artifacts_total` | gauge | | Number of cached artifacts |
2026-10-20 15:55:33 +05:30
| `proxy_metadata_cache_served_bytes_total` | counter | `ecosystem` | Metadata bytes served from the metadata cache. |
| `proxy_metadata_cache_size_bytes` | gauge | `ecosystem` | Size of cached metadata documents per ecosystem. |
| `proxy_metadata_cache_entries` | gauge | `ecosystem` | Number of cached metadata documents per ecosystem. |
Add age-based retention for cached artifacts (#428)
* Add age-based retention for cached artifacts
Cached artifacts stayed forever unless storage.max_size pushed them out.
storage.retention adds a default, per-ecosystem and per-package duration
after which an artifact nobody downloaded gets evicted.
Ecosystems opt in one at a time: a handler registers with
retention.Default, and until it does, configuring that ecosystem (or one
of its packages) fails validation, and the default doesn't touch it. This
commit registers none, so nothing changes yet. Startup logs which
ecosystems the default covers and which it doesn't.
An artifact counts as expired when both its fetch time and its last
access are older than the cutoff. Checking only the last access would
evict a refetched artifact again right away, since clearing a record
keeps its old access time and a cache miss doesn't record a hit.
The sweep scans artifacts in id windows, so no single query holds the
SQLite connection for long, and checks each row against its own rule.
Expired records are cleared and their files go through pending_deletes
instead of being deleted directly, because a buffered hit can make an
artifact that's being served right now look expired. Buffered hits are
flushed before each sweep, and one sweep clears at most 10,000
artifacts.
Since a big first sweep can queue a lot, reclaim now keeps working
through due entries for up to 30 seconds per tick instead of stopping
after 100, and pending_deletes gets an index on queued_at.
Also:
- proxy_artifacts_evicted_total{reason, ecosystem} counts LRU and
retention evictions.
- gradle.build_cache.max_age accepts "7d", as its comment always said.
Refs #306
* Document storage retention
Describes the new storage.retention block, the order rules apply in,
that the default only covers ecosystems that support retention yet, and
what to expect from the first sweep over an old cache. Also lists the
new eviction counter and notes that gradle max_age takes days.
Refs #306
* Fix retention timing on Postgres and tighten the sweep
On Postgres, fetched_at and last_accessed_at are TIMESTAMP columns
without a zone. They hold the local time the proxy wrote, but lib/pq
reads them back labelled UTC. East of UTC that makes every artifact look
hours younger than it is, so the sweep evicted late: two hours in
Berlin, nine in Tokyo. The candidates' times are now read back as local
time, and the database tests run on both SQLite and Postgres, with one
that sets the local zone to Asia/Tokyo.
A few smaller things:
- When a delete fails, reclaim stops for the rest of the minute instead
of trying every queued path. Otherwise a storage outage turns into
thirty seconds of failed deletes and warnings every minute.
- If the buffered hits can't be written, the sweep skips that round.
Running on stale access times could clear something that is being
downloaded right now.
- DefaultCanonical only accepts a package key when rebuilding it gives
the same PURL. For alpine and deb it would otherwise produce keys
that never match.
- sweep_interval has to be at least a minute.
- New tests cover the eviction counter and an artifact that gets a hit
between the scan and the clear.
- The docs no longer say nothing changes without retention, and they
point out that the example's ecosystem and package rules only pass
validation once those ecosystems support retention.
Refs #306
* Reject qualifiers and subpaths in retention package keys
A rule names a whole package. Qualifiers like ?type=pom were dropped on
the way to the stored PURL and the rule quietly covered every file of the
package. Now such a key fails validation.
Refs #306
* Say when a package key doesn't match how packages are stored
A package rule for an ecosystem that supports retention, written in a
form its packages aren't stored under (pkg:npm/babel/core without the
@), failed with "retention is not supported for npm packages". Once the
ecosystem is enabled that is no longer true. Such a key now fails with
"does not match how npm packages are stored" and points to the package
rule forms in the docs. Keys for ecosystems without retention support
keep the old message.
Refs #306
* Note that re-running the mirror keeps packages cached
A mirror run over a package that is still cached goes through the normal
cache lookup and records a download, so the package's age starts over.
The docs only said that mirrored packages age from the time they were
mirrored.
Refs #306
* Say when an ecosystem takes no package rules
Some ecosystems will support retention without package rules, because nothing in their stored records names a package the way a PURL would (conan, helm). A package key for one of them used to fail with "does not match how conan packages are stored; see the package rule forms", which sends people looking for a form that doesn't exist. It now says package rules aren't supported there and points to the ecosystem rule.
Refs #306
* Use helm as the example of an ecosystem without package rules
Conan gets package rules once its handler stores archives under the recipe name, so the config test that shows the refusal now uses helm, whose cache records name the repository, not the chart.
Refs #306
* Say that each cached file ages on its own
The retention docs said each artifact, each version of a package, ages on its own. A version can have several cached files (a jar and its pom, several wheels, Conan sources and binaries), and each of them ages separately.
Refs #306
* Clear an expired artifact and queue its file in one transaction
The sweep cleared the record and queued the file for deletion as two separate writes. If queueing failed, or the process stopped in between, the file stayed in storage with no record pointing at it and no queue entry, and no later sweep would find it again. Both writes now share a transaction, so a failed queue rolls the clear back and the next sweep retries.
Refs #306
* Show artifact evictions on the analytics page
proxy_artifacts_evicted_total had no place on /ui/analytics. The Runtime card now lists evicted artifacts by reason and ecosystem, without the failure tint, and the coverage test records an eviction so it doesn't depend on test order.
Refs #306
* Store artifact times in UTC
fetched_at and last_accessed_at are now written in UTC, and the retention cutoffs are compared in UTC, the same convention #434 uses for the metadata cache. lib/pq sends a time with its offset and a Postgres TIMESTAMP keeps only the wall clock, so UTC on write is what reads back correctly. That replaces reinterpreting the times as local on read.
Rows written before this still hold local time. East of UTC they look younger by the offset until they are rewritten, so retention evicts them a few hours late once; west of UTC that much early.
Refs #306
---------
Co-authored-by: t <t@t>
2026-10-12 10:06:01 +02:00
| `proxy_artifacts_evicted_total` | counter | `reason` , `ecosystem` | Artifacts evicted by the size limit (`lru` ) or by age (`retention` ) |
2026-10-20 11:22:55 +02:00
| `proxy_metadata_evicted_total` | counter | `ecosystem` | Cached metadata documents evicted by the size limit |
2026-03-12 12:18:27 +00:00
| `proxy_upstream_fetch_duration_seconds` | histogram | `ecosystem` | Time spent fetching from upstream |
| `proxy_upstream_errors_total` | counter | `ecosystem` , `error_type` | Upstream fetch failures |
| `proxy_storage_operation_duration_seconds` | histogram | `operation` | Storage read/write latency |
| `proxy_storage_errors_total` | counter | `operation` | Storage read/write failures |
| `proxy_active_requests` | gauge | | In-flight requests |
Add storage backend probe to /health (closes #73) (#119)
* config: add Health.StorageProbeInterval
* metrics: add proxy_health_probe_failures_total counter
* server: add storageProbe with happy-path test
* server: add storageProbe failure-mode tests
* server: add healthCache with TTL, single-flight, transition logging
* server: wire storage probe into /health
* server: update TestHealthEndpoint for JSON; wire healthCache into newTestServer
Also fix Windows file-locking issue in storageProbe: close the reader
explicitly before Delete so the file handle is released prior to os.Remove.
* server: clean up stale comment in storageProbe
* docs: document storage health probe and new metric
* docs: regenerate Swagger for /health JSON response
* server: simplify rc.Close error handling in storageProbe
* server: defer probe cleanup so size/open/read/verify failures don't leak objects
Previously, storageProbe only called Delete on the success path. Any
failure between Store and the final Delete (size mismatch, Open error,
mid-stream read failure, content mismatch) left the probe object orphaned
in the storage backend. With caching disabled and Kubernetes-rate probing,
the leak could accumulate noticeably on backends like S3.
Use a named return + defer to attempt Delete after every successful Store.
The earlier-step failure remains the primary error; Delete failure only
surfaces as step="delete" when nothing else went wrong. Add a table-driven
test that asserts cleanup runs for each non-delete failure path.
Reported by Copilot on #119.
* config: validate health.storage_probe_interval in Config.Validate
The new duration field was only validated at use time in newHealthCache.
The existing codebase already validates other duration fields
(MetadataTTL, DirectServeTTL, Gradle.MaxAge, Gradle.SweepInterval) in
Config.Validate() so misconfiguration fails fast at startup with a
config-key-specific error.
Match that pattern. The parse-at-use code in newHealthCache stays as
a safety net, mirroring the MetadataTTL precedent.
Reported by Copilot on #119.
* docs: lowercase "counter" in metrics table for consistency
Other rows in the table use lowercase type names (counter/gauge/histogram).
Match that style.
Reported by Copilot on #119.
* docs: include size-check step in /health probe description
The probe is write → size-check → read → verify → delete; the
architecture note was missing the size-check step.
Reported by Copilot on #119.
* server: address andrew's review on #119
- Drop unused callerCtx parameter from healthCache.Check (Check is now
parameter-less; the comment-only "accepted for symmetry" justification
wasn't carrying its weight).
- Emit "storage": {"status": "skipped"} on DB short-circuit instead of
omitting the key, so monitors expecting a fixed key set keep working.
- Reject negative storage_probe_interval at config validation time
(previously parsed and silently behaved like "0").
- Extract HealthConfig.Validate to keep Config.Validate under the
gocognit threshold and match the existing GradleBuildCacheConfig pattern.
- README Health Check section: note that /health is intended as a
readiness probe rather than a liveness probe (Check holds a mutex
for up to the 10s probe timeout).
- cmd/proxy/main.go godoc: column-align the new env var with the
surrounding Gradle entries.
Reported by andrew on #119.
2026-05-22 14:14:01 +03:00
| `proxy_health_probe_failures_total` | counter | `step` | Storage health probe failures by failing step (`write` , `size` , `read` , `verify` , `delete` ). |
2026-09-02 13:52:32 +02:00
| `proxy_circuit_breaker_state` | gauge | `registry` | Artifact-fetch circuit breaker state per upstream registry (0 closed, 2 open). Published once that registry's breaker has tripped. |
| `proxy_circuit_breaker_trips_total` | counter | `registry` | Circuit breaker trips per upstream registry. |
2026-10-14 12:51:43 +01:00
| `proxy_upstream_rate_limit_holds_total` | counter | `host` | Holds placed on an upstream host after it rate limited the proxy with HTTP 429, or 503 with a `Retry-After` . See `upstream_holds` under [Health Check ](#health-check ). |
2026-10-01 14:32:20 +02:00
| `proxy_ecosystem_downloaded_bytes` | gauge | `ecosystem` | Accumulated bytes served from cache: cache hits multiplied by the artifact size they served. |
| `proxy_ecosystem_artifact_downloads` | gauge | `ecosystem` | Accumulated artifact downloads served from cache. |
| `proxy_ecosystem_cache_size_bytes` | gauge | `ecosystem` | Size of cached artifacts per ecosystem. |
| `proxy_ecosystem_cached_artifacts` | gauge | `ecosystem` | Number of cached artifacts per ecosystem. |
| `proxy_ecosystem_packages` | gauge | `ecosystem` | Known packages per ecosystem. |
| `proxy_ecosystem_versions` | gauge | `ecosystem` | Known package versions per ecosystem. |
2026-10-01 14:32:20 +02:00
| `proxy_response_bytes_total` | counter | `ecosystem` | Response body bytes written to clients. Route-labelled, see the label caveat below. |
2026-10-01 14:32:21 +02:00
| `proxy_client_requests_total` | counter | `client` | Requests by client tool, from the User-Agent. |
| `proxy_client_response_bytes_total` | counter | `client` | Response bytes by client tool. |
2026-03-12 12:18:27 +00:00
2026-10-01 14:32:20 +02:00
The `ecosystem` label on `proxy_requests_total` and `proxy_request_duration_seconds` is the mounted route a request arrived on, not the ecosystem recorded against the package it served: `/gem` reports as `rubygems` , `/go` as `golang` , `/composer` as `packagist` , `/apk` as `alpine` and `/v2` as `oci` . Anything outside a package route -- the UI, `/health` , `/metrics` , `/stats` -- reports as `other` , and so did `/apk` , `/helm` , `/homebrew` , `/generic` and `/swift` before they were listed; traffic on those five routes now appears under its own name instead.
2026-10-01 14:32:20 +02:00
Cache size, artifact count and the per-ecosystem gauges are refreshed every 60 seconds, from a single pass over the database. Circuit breaker state is read from the fetcher on each scrape of `/metrics` and each `/health` request, so `proxy_circuit_breaker_trips_total` counts the trips visible between those reads — a breaker that opens and recovers entirely between two scrapes is not counted. The remaining metrics update on each request.
2026-09-02 13:52:32 +02:00
The breaker metrics carry one series per upstream host, but only for hosts whose breaker has tripped at least once since startup. A breaker is created per host the proxy fetches artifacts from, and for some ecosystems that host comes from upstream metadata rather than from configuration (composer takes it from a package's `dist.url` , helm from the chart URLs in `index.yaml` ), so publishing every host would let upstream content grow the series count for the lifetime of the process. Once a host has tripped it keeps reporting, so a recovery still shows up as a transition to 0 rather than as a series that vanishes. `/health` is not a persistent time series and lists every breaker, tripped or not.
The `registry` label is the host of the URL the artifact was fetched from. Because that URL can come from upstream metadata, it is not always one a host can be read off — a signed `dist.url` that fails to parse, for instance — and such a breaker is labelled `hostless-url-<digest>` instead, where the digest is keyed by a value drawn fresh at startup. Neither `/metrics` nor `/health` requires authentication, so a fetch URL is never published as a label or a key; the digest identifies the breaker for as long as the process runs without revealing the URL behind it or letting a chosen URL be matched against it.
Alert on `proxy_circuit_breaker_state == 2` sustained for more than a few minutes: while a breaker is open, artifact downloads for that upstream fail with HTTP 502 on every cache miss, and only a single probe request per backoff interval reaches the upstream. Cached artifacts keep serving, and so does metadata for the same ecosystem (metadata does not go through the circuit breaker), so installs fail in a way that looks like a partial upstream outage.
2026-03-12 12:18:27 +00:00
2026-10-01 14:32:20 +02:00
#### Accumulated download size
`proxy_ecosystem_downloaded_bytes` is, for every cached artifact, the number of times it was served multiplied by its size. It answers "how much traffic has this proxy actually carried", which is the number that matters when sizing egress or justifying the cache. Two properties are worth knowing before alerting on it.
**It counts cache hits, not upstream fetches.** The request that first pulls an artifact through the proxy is a miss and is not counted; only later hits are. So the accumulated total is also the upstream bandwidth the cache has saved, not the total bytes the proxy has ever sent.
**Eviction removes history.** Evicting an artifact clears its size, so its past hits drop out of the total. That is why these are gauges rather than counters, and why the figure can step downwards. Chart them with `max_over_time` rather than `increase` , and read a drop after an eviction sweep as expected rather than as data loss.
Nothing at the schema level ties `artifacts.version_purl` to a version row, so a cached artifact can end up with no ecosystem to attribute it to. Those are reported under the ecosystem `unattributed` rather than dropped, which keeps the per-ecosystem figures adding up to `proxy_cache_size_bytes` and `proxy_cached_artifacts_total` . A non-zero `unattributed` means the database holds artifact rows whose version or package rows have gone missing.
The `ecosystem` label on these six is taken from the package record and normalized, so aliases collapse: a database carrying both `gem` and `rubygems` rows -- the proxy writes the former, git-pkgs the latter -- reports one `rubygems` series with the two summed.
2026-10-01 14:32:20 +02:00
### Analytics
`/ui/analytics` reports the accumulated download size as a ring broken down by ecosystem, the cache figures from the dashboard, a per-ecosystem table, the vulnerability overview, and a **Runtime ** card covering every remaining metric `/metrics` exposes.
The ring shows at most six slices, because part-to-whole stops being readable past that. When more ecosystems are active the smallest are folded into a single "Other" slice; the table below lists every one of them, so nothing is hidden, only summarised.
#### No history is kept
The proxy stores no time series. The page reads the database and the in-process metric registry at request time and reports current state; there is nowhere for it to read yesterday's figures from, and nothing is written for tomorrow. That splits the figures in two, and the page says which is which.
**Database-derived figures survive a restart.** Download volume, cache size and the package, version and artifact counts come from the `artifacts` , `packages` and `versions` tables, so they are as durable as the database.
**Registry-derived figures do not.** Everything in the Runtime card -- request counts and latencies, cache hit rate, upstream and storage errors, circuit breaker state, scan results -- lives only in this process's Prometheus registry and starts from zero on restart. A small number there next to a large one above just means the proxy started recently.
For history, trends and alerting, scrape `/metrics` with Prometheus. That is the intended split: the UI answers "what is true now", Prometheus answers "what happened".
2026-10-01 14:32:20 +02:00
The same figures are available as JSON from `GET /stats` , which reports `downloaded_bytes` , `downloads` and an `ecosystems` array carrying the per-ecosystem breakdown, served from the same 60-second snapshot the page and the gauges read. When the aggregation fails with no snapshot to fall back on, the response carries `stats_unavailable: true` rather than passing zeros off as a count -- the endpoint keeps answering with the artifact count and cache size either way.
2026-10-20 15:55:33 +05:30
#### Artifact and metadata caches
The proxy keeps two caches. Artifacts (tarballs, wheels, crates and so on) live in storage and are what `proxy_cache_size_bytes` , `proxy_ecosystem_downloaded_bytes` and the other artifact figures describe. Registry metadata is cached separately when `cache_metadata` is on. It is stored persistently in the `metadata_cache` table rather than held in memory.
The `kind` label on `proxy_cache_hits_total` and `proxy_cache_misses_total` keeps the two apart, so an artifact hit rate is `sum(rate(proxy_cache_hits_total{kind="artifact"}[5m]))` over the same expression with misses added. Metadata has its own size and entry gauges plus `proxy_metadata_cache_served_bytes_total` . That one is a process counter rather than a database total, because metadata hits are not recorded per row. On `/ui/analytics` metadata appears in its own card and in the Runtime card's metadata lookups, never mixed into the artifact figures. The card and the two gauges read one 60-second snapshot, like the artifact figures. The bundled Grafana dashboard's hit ratio covers artifacts only, while its hits and misses panel shows each kind as its own series.
2026-10-01 14:32:20 +02:00
#### Three ecosystem label sets
`ecosystem` means three slightly different things across `/metrics` , and queries that join across them need to know which.
2026-10-20 15:55:33 +05:30
**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` .
2026-10-01 14:32:20 +02:00
**From the request path.** `proxy_requests_total` , `proxy_request_duration_seconds` and `proxy_response_bytes_total` . The names mostly coincide with the normalized ones -- these also report `rubygems` , `packagist` and `golang` -- but the Debian route reports `debian` where the package record says `deb` , and any path that is not a package endpoint reports `other` , which corresponds to no ecosystem at all.
**From the handler's own name.** `proxy_upstream_fetch_duration_seconds` and `proxy_upstream_errors_total` , which report `composer` , `gem` and `go` where the other two sets report `packagist` , `rubygems` and `golang` . These are published series and are deliberately left as they are; renaming them would break existing queries and alerts.
2026-10-01 14:32:21 +02:00
### Request sources
Package managers do not say who invoked them. A request from `pip` or `go` carries a `Host` , an `Accept` and a `User-Agent` -- no `Referer` , no originating URL, nothing naming a repository, pipeline or job. Whatever identity you want has to come from something on the wire, so the proxy attributes requests by the two things always present.
**Address** -- the TCP peer, or the leftmost `X-Forwarded-For` entry when `trust_forwarded_for` is enabled. Enable that only behind a load balancer or ingress that sets the header; any client can send it, so in front of one it lets a caller forge its own attribution and, by cycling synthetic addresses, fill the table and push every genuine caller into the overflow row. The totals stay correct; the attribution is what is lost.
**Client** -- the tool, taken from the leading User-Agent token: `pip` , `npm` , `go` , `docker` , `apt` , `curl` and so on. Anything unrecognised reports as `other` .
Set `ui_request_sources: true` and both appear on `/ui/analytics` under **Runtime -> Request sources ** , as a table of the busiest callers by bytes downloaded plus a per-tool breakdown. The table is in-memory and process-lifetime, like the rest of that card. It tracks 200 callers, evicting the least recently seen once full, and summarises everything it is not showing individually -- both evicted callers and those ranked below the display limit -- in a single "other callers" row, so the rows always add up to the totals above them.
**The flag defaults to off because the page is not authenticated.** `/ui` carries no auth of its own -- it is mounted under its own prefix so a reverse proxy * can * gate it separately, as [Behind a Reverse Proxy ](#behind-a-reverse-proxy ) describes, but nothing makes you -- and until now it exposed only package data. The sources table changes what is on offer: anyone who can reach the proxy can read the addresses of your build fleet, which tool each runs, and how much each pulled. Turn it on once `/ui` is gated, or leave it off and read the same detail from the access log.
**What this can and cannot tell you.** How much an address gives you depends entirely on your network. A fleet of build machines with stable addresses attributes cleanly. Containerised CI usually does not: with Docker or Kubernetes executors every job gets an ephemeral address, and egress is commonly NAT'd behind one gateway, so you get runner-node or gateway granularity, not per-project. If you need per-project attribution the caller has to send something naming itself -- a basic-auth username, or a per-project base URL -- which the proxy does not currently read. Say so and it can be added.
**Why addresses are not Prometheus labels.** Client tool names are exported as `proxy_client_requests_total{client}` because they come from a closed set. Addresses are not exported at all: the caller set is unbounded and outside the proxy's control, and every new address would create a time series that lives forever in your TSDB. The same goes for anything job-scoped -- a pipeline ID must never become a label. Per-address and per-request detail belongs in the access log, which records `remote_ip` , `user_agent` , `client` , `ecosystem` and `bytes` on every line as JSONL, ready for `jq` , Loki or whatever you ship logs to.
2026-10-12 09:41:42 +02:00
### Failure details
The failure counts under **Runtime ** on `/ui/analytics` -- upstream errors, storage errors, integrity failures, health probe failures and scan errors -- say how often something failed, not what. Set `ui_failure_details: true` and each count gets a **Latest ** list underneath it, newest first: the package URL and filename, the upstream URL or storage path, the error, and how long ago it happened.
The list keeps the last 20 failures per row, in memory only, so it starts empty when the proxy starts, like the counts above it. Credentials, query strings and fragments are stripped from every URL before it is kept, including URLs inside error messages, since that is where registry tokens and signed-URL signatures live.
The flag defaults to off for the same reason as `ui_request_sources` : package names and upstream hosts say what your users depend on, and `/ui` is not authenticated. Package names and URLs are never Prometheus labels; the same detail is in the proxy's log.
2026-10-01 14:32:20 +02:00
### Grafana dashboard
A ready-made dashboard lives at [`deploy/grafana/git-pkgs-proxy.json` ](deploy/grafana/git-pkgs-proxy.json ). Import it via **Dashboards -> New -> Import ** and pick your Prometheus data source when prompted; it has no hardcoded data source UID.
It carries three ecosystem filters rather than one, because `ecosystem` means three different things across `/metrics` -- see the label sets above. **Ecosystem ** filters the database-derived gauges, **Route ** the request-path counters, and **Upstream ** the two upstream fetch metrics. All three are query variables, so they populate from whatever labels your proxy is actually reporting; a panel is on the one its metric belongs to, and the panel descriptions say which.
Add storage backend probe to /health (closes #73) (#119)
* config: add Health.StorageProbeInterval
* metrics: add proxy_health_probe_failures_total counter
* server: add storageProbe with happy-path test
* server: add storageProbe failure-mode tests
* server: add healthCache with TTL, single-flight, transition logging
* server: wire storage probe into /health
* server: update TestHealthEndpoint for JSON; wire healthCache into newTestServer
Also fix Windows file-locking issue in storageProbe: close the reader
explicitly before Delete so the file handle is released prior to os.Remove.
* server: clean up stale comment in storageProbe
* docs: document storage health probe and new metric
* docs: regenerate Swagger for /health JSON response
* server: simplify rc.Close error handling in storageProbe
* server: defer probe cleanup so size/open/read/verify failures don't leak objects
Previously, storageProbe only called Delete on the success path. Any
failure between Store and the final Delete (size mismatch, Open error,
mid-stream read failure, content mismatch) left the probe object orphaned
in the storage backend. With caching disabled and Kubernetes-rate probing,
the leak could accumulate noticeably on backends like S3.
Use a named return + defer to attempt Delete after every successful Store.
The earlier-step failure remains the primary error; Delete failure only
surfaces as step="delete" when nothing else went wrong. Add a table-driven
test that asserts cleanup runs for each non-delete failure path.
Reported by Copilot on #119.
* config: validate health.storage_probe_interval in Config.Validate
The new duration field was only validated at use time in newHealthCache.
The existing codebase already validates other duration fields
(MetadataTTL, DirectServeTTL, Gradle.MaxAge, Gradle.SweepInterval) in
Config.Validate() so misconfiguration fails fast at startup with a
config-key-specific error.
Match that pattern. The parse-at-use code in newHealthCache stays as
a safety net, mirroring the MetadataTTL precedent.
Reported by Copilot on #119.
* docs: lowercase "counter" in metrics table for consistency
Other rows in the table use lowercase type names (counter/gauge/histogram).
Match that style.
Reported by Copilot on #119.
* docs: include size-check step in /health probe description
The probe is write → size-check → read → verify → delete; the
architecture note was missing the size-check step.
Reported by Copilot on #119.
* server: address andrew's review on #119
- Drop unused callerCtx parameter from healthCache.Check (Check is now
parameter-less; the comment-only "accepted for symmetry" justification
wasn't carrying its weight).
- Emit "storage": {"status": "skipped"} on DB short-circuit instead of
omitting the key, so monitors expecting a fixed key set keep working.
- Reject negative storage_probe_interval at config validation time
(previously parsed and silently behaved like "0").
- Extract HealthConfig.Validate to keep Config.Validate under the
gocognit threshold and match the existing GradleBuildCacheConfig pattern.
- README Health Check section: note that /health is intended as a
readiness probe rather than a liveness probe (Check holds a mutex
for up to the 10s probe timeout).
- cmd/proxy/main.go godoc: column-align the new env var with the
surrounding Gradle entries.
Reported by andrew on #119.
2026-05-22 14:14:01 +03:00
### Health Check
`/health` returns a structured JSON report of subsystem health. HTTP 200 if all checks pass; 503 if any fail.
```json
{
"status": "ok",
"checks": {
"database": {"status": "ok"},
"storage": {"status": "ok"}
2026-09-02 13:52:32 +02:00
},
"circuit_breakers": {
"registry.npmjs.org": "closed",
"static.crates.io": "open"
2026-10-14 12:51:43 +01:00
},
"upstream_holds": {
"repo1.maven.org": "2026-10-14T11:15:29Z"
Add storage backend probe to /health (closes #73) (#119)
* config: add Health.StorageProbeInterval
* metrics: add proxy_health_probe_failures_total counter
* server: add storageProbe with happy-path test
* server: add storageProbe failure-mode tests
* server: add healthCache with TTL, single-flight, transition logging
* server: wire storage probe into /health
* server: update TestHealthEndpoint for JSON; wire healthCache into newTestServer
Also fix Windows file-locking issue in storageProbe: close the reader
explicitly before Delete so the file handle is released prior to os.Remove.
* server: clean up stale comment in storageProbe
* docs: document storage health probe and new metric
* docs: regenerate Swagger for /health JSON response
* server: simplify rc.Close error handling in storageProbe
* server: defer probe cleanup so size/open/read/verify failures don't leak objects
Previously, storageProbe only called Delete on the success path. Any
failure between Store and the final Delete (size mismatch, Open error,
mid-stream read failure, content mismatch) left the probe object orphaned
in the storage backend. With caching disabled and Kubernetes-rate probing,
the leak could accumulate noticeably on backends like S3.
Use a named return + defer to attempt Delete after every successful Store.
The earlier-step failure remains the primary error; Delete failure only
surfaces as step="delete" when nothing else went wrong. Add a table-driven
test that asserts cleanup runs for each non-delete failure path.
Reported by Copilot on #119.
* config: validate health.storage_probe_interval in Config.Validate
The new duration field was only validated at use time in newHealthCache.
The existing codebase already validates other duration fields
(MetadataTTL, DirectServeTTL, Gradle.MaxAge, Gradle.SweepInterval) in
Config.Validate() so misconfiguration fails fast at startup with a
config-key-specific error.
Match that pattern. The parse-at-use code in newHealthCache stays as
a safety net, mirroring the MetadataTTL precedent.
Reported by Copilot on #119.
* docs: lowercase "counter" in metrics table for consistency
Other rows in the table use lowercase type names (counter/gauge/histogram).
Match that style.
Reported by Copilot on #119.
* docs: include size-check step in /health probe description
The probe is write → size-check → read → verify → delete; the
architecture note was missing the size-check step.
Reported by Copilot on #119.
* server: address andrew's review on #119
- Drop unused callerCtx parameter from healthCache.Check (Check is now
parameter-less; the comment-only "accepted for symmetry" justification
wasn't carrying its weight).
- Emit "storage": {"status": "skipped"} on DB short-circuit instead of
omitting the key, so monitors expecting a fixed key set keep working.
- Reject negative storage_probe_interval at config validation time
(previously parsed and silently behaved like "0").
- Extract HealthConfig.Validate to keep Config.Validate under the
gocognit threshold and match the existing GradleBuildCacheConfig pattern.
- README Health Check section: note that /health is intended as a
readiness probe rather than a liveness probe (Check holds a mutex
for up to the 10s probe timeout).
- cmd/proxy/main.go godoc: column-align the new env var with the
surrounding Gradle entries.
Reported by andrew on #119.
2026-05-22 14:14:01 +03:00
}
}
```
Failing checks include an `"error"` field. Storage failures also include a `"step"` field identifying which probe step failed (`write` , `size` , `read` , `verify` , `delete` ). When the database check fails, the storage entry reports `{"status": "skipped"}` so the response always carries the same key set.
2026-09-02 13:52:32 +02:00
`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.
2026-10-14 12:51:43 +01:00
`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.
2026-09-02 13:52:32 +02:00
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.
Add storage backend probe to /health (closes #73) (#119)
* config: add Health.StorageProbeInterval
* metrics: add proxy_health_probe_failures_total counter
* server: add storageProbe with happy-path test
* server: add storageProbe failure-mode tests
* server: add healthCache with TTL, single-flight, transition logging
* server: wire storage probe into /health
* server: update TestHealthEndpoint for JSON; wire healthCache into newTestServer
Also fix Windows file-locking issue in storageProbe: close the reader
explicitly before Delete so the file handle is released prior to os.Remove.
* server: clean up stale comment in storageProbe
* docs: document storage health probe and new metric
* docs: regenerate Swagger for /health JSON response
* server: simplify rc.Close error handling in storageProbe
* server: defer probe cleanup so size/open/read/verify failures don't leak objects
Previously, storageProbe only called Delete on the success path. Any
failure between Store and the final Delete (size mismatch, Open error,
mid-stream read failure, content mismatch) left the probe object orphaned
in the storage backend. With caching disabled and Kubernetes-rate probing,
the leak could accumulate noticeably on backends like S3.
Use a named return + defer to attempt Delete after every successful Store.
The earlier-step failure remains the primary error; Delete failure only
surfaces as step="delete" when nothing else went wrong. Add a table-driven
test that asserts cleanup runs for each non-delete failure path.
Reported by Copilot on #119.
* config: validate health.storage_probe_interval in Config.Validate
The new duration field was only validated at use time in newHealthCache.
The existing codebase already validates other duration fields
(MetadataTTL, DirectServeTTL, Gradle.MaxAge, Gradle.SweepInterval) in
Config.Validate() so misconfiguration fails fast at startup with a
config-key-specific error.
Match that pattern. The parse-at-use code in newHealthCache stays as
a safety net, mirroring the MetadataTTL precedent.
Reported by Copilot on #119.
* docs: lowercase "counter" in metrics table for consistency
Other rows in the table use lowercase type names (counter/gauge/histogram).
Match that style.
Reported by Copilot on #119.
* docs: include size-check step in /health probe description
The probe is write → size-check → read → verify → delete; the
architecture note was missing the size-check step.
Reported by Copilot on #119.
* server: address andrew's review on #119
- Drop unused callerCtx parameter from healthCache.Check (Check is now
parameter-less; the comment-only "accepted for symmetry" justification
wasn't carrying its weight).
- Emit "storage": {"status": "skipped"} on DB short-circuit instead of
omitting the key, so monitors expecting a fixed key set keep working.
- Reject negative storage_probe_interval at config validation time
(previously parsed and silently behaved like "0").
- Extract HealthConfig.Validate to keep Config.Validate under the
gocognit threshold and match the existing GradleBuildCacheConfig pattern.
- README Health Check section: note that /health is intended as a
readiness probe rather than a liveness probe (Check holds a mutex
for up to the 10s probe timeout).
- cmd/proxy/main.go godoc: column-align the new env var with the
surrounding Gradle entries.
Reported by andrew on #119.
2026-05-22 14:14:01 +03:00
Storage probe results are cached for `health.storage_probe_interval` (default 30s) to bound the cost of probing remote backends. A probe holds an internal mutex for up to 10 seconds (the hardcoded per-probe timeout), so `/health` is intended as a Kubernetes **readiness ** probe rather than a liveness probe — a slow S3 round-trip should pull the pod from rotation, not restart it.
2026-03-12 12:18:27 +00:00
Scrape config for Prometheus:
```yaml
scrape_configs:
- job_name: git-pkgs-proxy
static_configs:
- targets: ["localhost:8080"]
```
2026-01-20 21:52:44 +00:00
## Production Deployment
### Systemd Service
Create `/etc/systemd/system/proxy.service` :
```ini
[Unit]
Description=git-pkgs proxy
After=network.target
[Service]
Type=simple
User=proxy
ExecStart=/usr/local/bin/proxy -config /etc/proxy/config.yaml
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
```
Enable and start:
```bash
sudo systemctl enable proxy
sudo systemctl start proxy
```
### Docker
2026-03-18 11:15:45 +00:00
A Dockerfile is included in the repo. Build and run:
2026-01-20 21:52:44 +00:00
2026-03-18 11:15:45 +00:00
```bash
docker build -t proxy .
docker run -p 8080:8080 -v proxy-data:/data proxy
2026-01-20 21:52:44 +00:00
```
2026-03-18 11:15:45 +00:00
With Postgres and S3:
2026-01-20 21:52:44 +00:00
```bash
2026-03-18 11:15:45 +00:00
docker run -p 8080:8080 \
-e PROXY_DATABASE_DRIVER=postgres \
-e PROXY_DATABASE_URL=postgres://user:pass@db:5432/proxy \
-e PROXY_STORAGE_URL=s3://my-bucket?region=us-east-1 \
-e AWS_ACCESS_KEY_ID=... \
-e AWS_SECRET_ACCESS_KEY=... \
proxy
2026-01-20 21:52:44 +00:00
```
### Behind a Reverse Proxy
When running behind nginx, Apache, or another reverse proxy, set `base_url` to your public URL:
```yaml
base_url: "https://proxy.example.com"
```
2026-06-07 16:12:43 +01:00
If the UI is reached on a different hostname than the package endpoints — for example, the UI exposed publicly on a domain while build machines hit a Docker network alias — set `ui_base_url` separately. `base_url` is the URL package managers and metadata rewriting use; `ui_base_url` is the URL advertised to humans visiting the web UI (canonical/`og:url` tags and the install guide banner):
2026-01-20 21:52:44 +00:00
2026-06-07 16:12:43 +01:00
```yaml
base_url: "http://pkg-proxy:8080" # internal alias for build machines
ui_base_url: "https://proxy.example.com/ui" # public UI URL
2026-01-20 21:52:44 +00:00
```
2026-06-07 16:12:43 +01:00
When unset, `ui_base_url` defaults to `base_url` .
> **Warning:** the proxy serves the UI and package endpoints on the same listener. Setting `ui_base_url` only changes what URL the UI advertises to humans; it does not stop package endpoints from being reachable on the same hostname and port. When fronting the proxy with a public reverse proxy, restrict the public route to `PathPrefix(/ui)` (or your proxy's equivalent), otherwise `/npm`, `/pypi`, and the other package endpoints stay exposed alongside the UI.
nginx example, restricting the public host to the UI while leaving package endpoints reachable only on the internal listener:
2026-06-07 15:47:29 +01:00
```nginx
server {
listen 443 ssl;
server_name proxy.example.com;
location /ui/ {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_buffering off;
}
location / {
2026-06-07 16:12:43 +01:00
return 404;
2026-06-07 15:47:29 +01:00
}
}
```
2026-06-07 16:12:43 +01:00
Traefik example using `PathPrefix(/ui)` so the public router only matches UI traffic:
```yaml
labels:
traefik.enable: "true"
traefik.http.services.pkg-proxy.loadbalancer.server.port: "8080"
traefik.http.routers.pkg-proxy.rule: "Host(`proxy.example.com` ) && PathPrefix(`/ui` )"
traefik.http.routers.pkg-proxy.entrypoints: "websecure"
```
2026-01-20 21:52:44 +00:00
## Cache Management
The proxy stores artifacts in the configured storage directory with this structure:
```
cache/artifacts/
├── npm/
│ └── lodash/
│ └── 4.17.21/
│ └── lodash-4.17.21.tgz
2026-01-29 19:35:15 +00:00
├── cargo/
│ └── serde/
│ └── 1.0.193/
│ └── serde-1.0.193.crate
├── oci/
│ └── library/nginx/
│ └── sha256:abc123.../
│ └── sha256:abc123...
├── deb/
│ └── nginx/
│ └── 1.18.0-6/
│ └── nginx_1.18.0-6_amd64.deb
└── rpm/
└── nginx/
└── 1.24.0-1.fc39/
└── nginx-1.24.0-1.fc39.x86_64.rpm
2026-01-20 21:52:44 +00:00
```
2026-10-20 11:35:08 +01:00
To delete one version's cached files, so the next request fetches them again, enable `cache_delete` and use the Delete buttons on the version page in the web UI, or the [cache API ](docs/configuration.md#cache-delete-api ):
```bash
curl -X DELETE 'http://localhost:8080/api/cache/maven/com.google.guava:guava/32.1.3-jre?filename=guava-32.1.3-jre.jar'
```
2026-03-18 11:15:45 +00:00
Cache metadata is stored in SQLite (default) or PostgreSQL. To clear a local cache:
2026-01-20 21:52:44 +00:00
```bash
rm -rf ./cache/artifacts/*
rm ./cache/proxy.db
```
The proxy will recreate the database on next start.
## Building from Source
Requirements:
2026-05-22 18:05:20 +02:00
2026-08-16 18:22:26 +01:00
- Go (the project version is declared in `go.mod` )
2026-01-20 21:52:44 +00:00
```bash
git clone https://github.com/git-pkgs/proxy.git
cd proxy
go build -o proxy ./cmd/proxy
```
Run tests:
```bash
go test ./...
```
## License
2026-09-23 15:33:15 +01:00
[GPL-3.0-or-later ](LICENSE ).