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

448 lines
16 KiB
YAML
Raw Permalink Normal View History

# Proxy server configuration
# Copy to config.yaml and modify as needed
# Server listen address
listen: ":8080"
# Public URL where package endpoints are reachable.
# Used for rewriting package metadata URLs and shown in install guide snippets
# so users know what to point their package manager at.
base_url: "http://localhost:8080"
# Timeout for individual upstream HTTP requests made by protocol handlers
# (metadata fetches, pass-through file requests). Uses Go duration syntax.
# Set to "0" to disable the timeout. Default: "30s".
# http_timeout: "30s"
# Largest upstream artifact to fetch (e.g. "2GB"). Larger ones are refused
# and not cached. Default: unlimited.
# artifact_max_size: ""
Cache rewritten npm and Composer metadata (#402) The npm and Composer handlers rewrite every metadata document they serve so that download URLs point at the proxy: decode the whole document into generic maps, change the URLs, encode it again, and for Composer expand the minified format first. That ran on every request, cached metadata included. With metadata caching on and upstream out of the picture, a cached request still cost 7 ms for @babel/core, 17 ms for symfony/console and 155 ms and 134 MB of allocations for typescript, and an 8 vCPU VM serving cached npm packuments to 20 clients ran the proxy at 450% CPU for about 1,100 requests a second. Rewritten documents are now kept in memory, keyed by the ecosystem, proxy URL, package and a SHA-256 of the raw document, so new bytes from upstream are rewritten again and nothing is served stale. Requests that arrive while a document is being rewritten wait for that rewrite rather than running their own; a waiter leaves when its client does, and the rewrite still completes and is cached. The denylist is fixed at startup, so it needs no place in the key. Cooldown filtering depends on the current time, so with cooldown on the cache is bypassed. metadata_rewrite_cache_size bounds the cache (default "256MB", least recently used out first, "0" to rewrite on every request). NewProxy callers keep the old behaviour unless they set it. Whole cached requests, measured locally: @babel/core 7.1 ms -> 0.38 ms, 54,489 -> 119 allocations typescript 155 ms -> 10.5 ms, 1.37M -> 133 allocations symfony/console 17.4 ms -> 0.48 ms, 165,909 -> 123 allocations What remains is reading the raw document from storage and hashing it.
2026-10-03 04:09:15 -07:00
# Memory for rewritten npm and Composer metadata, so each upstream document is
# rewritten and gzip-compressed once rather than on every request. Set to "0"
# to rewrite on every request. Default: "256MB".
Cache rewritten npm and Composer metadata (#402) The npm and Composer handlers rewrite every metadata document they serve so that download URLs point at the proxy: decode the whole document into generic maps, change the URLs, encode it again, and for Composer expand the minified format first. That ran on every request, cached metadata included. With metadata caching on and upstream out of the picture, a cached request still cost 7 ms for @babel/core, 17 ms for symfony/console and 155 ms and 134 MB of allocations for typescript, and an 8 vCPU VM serving cached npm packuments to 20 clients ran the proxy at 450% CPU for about 1,100 requests a second. Rewritten documents are now kept in memory, keyed by the ecosystem, proxy URL, package and a SHA-256 of the raw document, so new bytes from upstream are rewritten again and nothing is served stale. Requests that arrive while a document is being rewritten wait for that rewrite rather than running their own; a waiter leaves when its client does, and the rewrite still completes and is cached. The denylist is fixed at startup, so it needs no place in the key. Cooldown filtering depends on the current time, so with cooldown on the cache is bypassed. metadata_rewrite_cache_size bounds the cache (default "256MB", least recently used out first, "0" to rewrite on every request). NewProxy callers keep the old behaviour unless they set it. Whole cached requests, measured locally: @babel/core 7.1 ms -> 0.38 ms, 54,489 -> 119 allocations typescript 155 ms -> 10.5 ms, 1.37M -> 133 allocations symfony/console 17.4 ms -> 0.48 ms, 165,909 -> 123 allocations What remains is reading the raw document from storage and hashing it.
2026-10-03 04:09:15 -07:00
# metadata_rewrite_cache_size: "256MB"
# Public URL where the web UI is reached. Defaults to base_url when unset.
# Set this separately when the UI is served on a different hostname than the
# package endpoints — for example, the UI on a public domain behind auth while
# build machines hit a Docker network alias for the package endpoints.
# ui_base_url: "https://proxy.example.com/ui"
# Attribute requests to the leftmost X-Forwarded-For entry rather than the TCP
# peer address, in the structured log, the access log and the request-source
# table on /ui/analytics.
#
# Enable this only when the proxy sits behind a load balancer or ingress that
# sets the header. Any client can send it: behind one it is the only way to see
# past the hop, in front of one it lets a caller forge its own address.
# trust_forwarded_for: false
# Show the request-source table on /ui/analytics: caller addresses, the tool
# each ran and how much each pulled.
#
# Off by default. The proxy has no authentication of its own, so leave this off
# unless /ui is gated by a reverse proxy; anyone who can reach the page can
# otherwise read the addresses of your build fleet.
# ui_request_sources: false
# List the latest failures behind each failure count on /ui/analytics (upstream,
# storage, integrity, health probe and scan errors): the package, its upstream
# URL or storage path, and the error. URLs lose credentials and query strings.
#
# Off by default, for the same reason as ui_request_sources.
# ui_failure_details: false
# Artifact storage configuration
storage:
# Storage backend URL
# Supported schemes:
# - file:///path/to/dir - Local filesystem (default)
# - s3://bucket-name - Amazon S3
# - s3://bucket?endpoint=http://localhost:9000 - S3-compatible (MinIO)
# - gs://bucket-name - Google Cloud Storage
# - azblob://container-name - Azure Blob Storage
#
# For S3, configure credentials via environment variables:
# AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION
#
# For GCS, authentication uses Application Default Credentials. On GKE with
# Workload Identity, bind the Kubernetes service account to a Google service
# account that has roles/storage.objectAdmin on the bucket. No extra config
# is needed in this file. For local development, run:
# gcloud auth application-default login
# If direct_serve is enabled, the service account also needs
# roles/iam.serviceAccountTokenCreator on itself so the IAM Credentials
# signBlob API can sign URLs without a private key.
url: ""
# Local filesystem path (used when url is empty)
# Deprecated: Use url with file:// scheme instead
path: "./cache/artifacts"
# Maximum cache size (e.g., "10GB", "500MB")
# When exceeded, least recently used artifacts are evicted
# Empty or "0" means unlimited
max_size: ""
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
# Evict artifacts that have not been served for longer than a duration
# ("30d", "72h"), independent of max_size. Resolution order: package rule,
# then ecosystem rule, then default. "0" or empty means never evict by age.
# The default only applies to ecosystems that support retention; naming an
# ecosystem or package that does not is a configuration error. See
# docs/configuration.md for the supported ecosystems.
retention:
default: ""
# ecosystems:
# npm: "30d"
# packages:
# "pkg:npm/lodash": "0"
# How often to look for expired artifacts; at least "1m".
sweep_interval: "10m"
Add storage.cache_artifacts to serve artifacts without storing them (#387) * Add storage.passthrough to serve artifacts without storing them With storage.passthrough enabled the proxy streams every artifact from upstream to the client and never writes it to storage or the cache database. Metadata filtering, cooldown and the denylist work as before, so the proxy can sit behind another cache (e.g. an Artifactory remote) purely as a policy layer without holding a second copy of every package. Artifacts whose digest is known up front (OCI, Swift, Helm) are verified while streaming. Their responses are sent chunked and the connection is aborted on a mismatch, so a client never receives a tampered artifact as a complete response. Passthrough is rejected together with scanning, direct_serve and mirror_api, and the mirror command refuses to run with it, since all of them depend on stored artifacts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Address review: abort truncated streams, guard every cache read - serveArtifact aborts the response on any body read error, and when fewer bytes than the declared size were written, not only on a digest mismatch. A failed or short upstream body in passthrough mode no longer reaches the client as a complete 200. - Streamed upstream read failures are logged and counted as stream_failed upstream errors before the response is aborted. - checkCache reports a miss when artifact caching is off, so every cache read path, including Swift archive HEAD requests, ignores entries stored before the mode was enabled. Denylisted versions are still rejected. - Rename storage.passthrough to storage.cache_artifacts (default true, PROXY_STORAGE_CACHE_ARTIFACTS), which names what actually changes. Tests cover an OCI blob shorter than its Content-Length and an npm tarball whose chunked response ends without the final chunk, both served through the HTTP handlers, plus a Swift HEAD request against a cached archive. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Rename Proxy.Passthrough to StreamArtifacts Match the storage.cache_artifacts option: the field says what changes (artifacts are streamed instead of stored) and its zero value keeps the usual caching behaviour for every Proxy built without the server config, such as the mirror command and tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:28:20 +07:00
# Store fetched artifacts. Set to false to stream every download from
# upstream without storing it; metadata filtering, cooldown and the
# denylist still apply. Useful when another cache sits in front of the
# proxy. false is incompatible with direct_serve, scanning and mirror_api.
cache_artifacts: true
# Redirect cached artifact downloads to presigned storage URLs (HTTP 302)
# instead of streaming through the proxy. Only effective for S3, GCS, and Azure.
# Leave disabled if clients reach the proxy through an authenticating gateway,
# since presigned URLs bypass it.
direct_serve: false
# How long presigned URLs remain valid (e.g. "5m", "1h"). Default: "15m".
direct_serve_ttl: "15m"
# Public base URL to substitute into presigned URLs. Set this when the
# proxy reaches storage at an internal address (127.0.0.1, a Docker
# service name) but clients must use a public hostname. Only scheme and
# host are used; the signed path and query are preserved. For S3/MinIO
# the reverse proxy at this address must forward requests with the
# internal Host header or the SigV4 signature will not validate.
# direct_serve_base_url: "https://minio.example.com"
# Database configuration
database:
# Database driver: "sqlite" (default) or "postgres"
driver: "sqlite"
# SQLite database file path (used when driver is "sqlite")
path: "./cache/proxy.db"
# PostgreSQL connection URL (used when driver is "postgres")
# Example: "postgres://user:password@localhost:5432/proxy?sslmode=disable"
url: ""
# How often cache hit counts and last-access times are written, batched in
# one transaction. "0" writes each hit as it happens.
hit_flush_interval: "1s"
# Logging configuration
log:
# Minimum log level: "debug", "info", "warn", "error"
level: "info"
# Log format: "text" or "json"
format: "text"
# JSONL access log. Leave path empty to disable it.
access_log:
path: ""
# Upstream URLs for built-in routes and authentication
upstream:
# Hosts allowed to resolve to private, ULA, or CGNAT addresses
allow_private_hosts: []
# Permit upstream requests and redirects to loopback addresses
allow_loopback: false
# npm registry URL
npm: "https://registry.npmjs.org"
# Always request full npm packuments so served metadata carries publish
# times ("time" map) even when cooldown is disabled. Needed by clients that
# gate on publish age, e.g. Yarn's npmMinimalAgeGate. Default: false.
# npm_full_metadata: true
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
# npm packages served only from a private registry, by name pattern.
# Matching packages are never looked up on the npm registry above.
# npm_routes:
# "@example/*": "https://npm.example.com/registry"
# Cargo sparse index URL
cargo: "https://index.crates.io"
# Cargo crate download URL
cargo_download: "https://static.crates.io/crates"
# RubyGems registry URL
gem: "https://rubygems.org"
# Go module proxy URL
go: "https://proxy.golang.org"
# Hex repository URL
hex: "https://repo.hex.pm"
# Hex API URL used for package timestamps
hex_api: "https://hex.pm"
# pub registry URL
pub: "https://pub.dev"
# PyPI index and API URL
pypi: "https://pypi.org"
# PyPI package download URL
pypi_download: "https://files.pythonhosted.org"
# Maven repository URL (used by /maven endpoint)
maven: "https://repo1.maven.org/maven2"
# Gradle Plugin Portal Maven URL (fallback for plugin marker artifacts)
gradle_plugin_portal: "https://plugins.gradle.org/m2"
# NuGet API URL
nuget: "https://api.nuget.org"
# NuGet search API URL
nuget_search: "https://azuresearch-usnc.nuget.org"
# Packagist API URL
composer: "https://packagist.org"
# Packagist repository URL
composer_repository: "https://repo.packagist.org"
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
# Composer packages served only from a private repository, by name pattern.
# Matching packages are never looked up on composer_repository.
# composer_routes:
# "example/*": "https://composer.example.com/packages"
# Conan registry URL
conan: "https://center.conan.io"
# Conda channel base URL
conda: "https://conda.anaconda.org"
# CRAN mirror URL
cran: "https://cloud.r-project.org"
# Julia package server URL
julia: "https://pkg.julialang.org"
# Swift Package Registry URL (used by /swift endpoint)
swift: "https://tuist.dev/api/registry/swift"
# Default OCI registry URL for unprefixed /v2 requests
oci_default: "https://registry-1.docker.io"
# Debian/APT repository URL (used by /debian endpoint)
debian: "http://deb.debian.org/debian"
# Additional Debian/APT archives, served at /debian/{name}/.
# The names "pool" and "dists" are reserved for the main archive's own paths.
debian_repositories:
security: "https://security.debian.org/debian-security"
# RPM repository URL (used by /rpm endpoint)
rpm: "https://dl.fedoraproject.org/pub/fedora/linux"
2026-09-03 16:59:12 +01:00
# Homebrew JSON API URL (used by /homebrew endpoint)
homebrew_api: "https://formulae.brew.sh/api"
# Homebrew artifact registry URL (used for /v2/homebrew/core requests)
homebrew_artifact: "https://ghcr.io"
# Named HTTP Helm chart repositories (used by /helm/{name}/)
# helm:
# bitnami: "https://charts.bitnami.com/bitnami"
# Named OCI registries. Use the upstream/{name}/ repository prefix, e.g.
# oci://proxy.example.com/upstream/ghcr/owner/chart.
# oci:
# ghcr: "https://ghcr.io"
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
# Registries a named OCI upstream mirrors, e.g. when ghcr above points at
# an Artifactory remote of ghcr.io. containerd mirror requests whose ns
# parameter names one of these hosts are served by that upstream.
# oci_mirrors:
# ghcr: ["ghcr.io"]
# Named Alpine APK repositories (used by /apk/{name}/).
# Defaults to {"alpine": "https://dl-cdn.alpinelinux.org/alpine"} when empty;
# configuring any entry replaces that default.
# apk:
# alpine: "https://dl-cdn.alpinelinux.org/alpine"
# private: "https://apk.example.com"
# Named Arch Linux pacman mirrors (used by /arch/{name}/).
# Defaults to {"archlinux": "https://geo.mirror.pkgbuild.com"} when empty;
# configuring any entry replaces that default. Arch Linux ARM needs a
# regional HTTPS mirror of your choice, not the mirror.archlinuxarm.org
# redirector.
# arch:
# archlinux: "https://geo.mirror.pkgbuild.com"
# archlinuxarm: "https://<region>.mirror.archlinuxarm.org"
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
# Named generic HTTP upstreams (used by /generic/{name}/). The remaining
# request path and query are appended to the upstream URL. GitHub release
# assets ({owner}/{repo}/releases/download/{tag}/{asset}) are cached
# immutably; other paths use the metadata cache with stale-on-error.
# generic:
# github: "https://github.com"
# github-api: "https://api.github.com"
# apache: "https://archive.apache.org"
# Version-pinned paths of generic upstreams, cached immutably like GitHub
# release assets. Each pattern must match the whole path after
# /generic/{name}/ and have `version` and `file` named groups; an optional
# `name` group names the package.
# generic_artifacts:
# apache:
# - 'dist/(?P<name>maven)/maven-3/(?P<version>[^/]+)/binaries/(?P<file>[^/]+)'
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
# Authentication for upstream registries
# Keys are absolute URL scopes. Scheme, host, effective port, and path
# segment boundaries must match; the longest matching scope wins.
# Values can reference environment variables using ${VAR_NAME} syntax.
#
# Supported auth types:
# - bearer: Authorization header with Bearer token
# - basic: Authorization header with Basic auth (username:password)
# - header: Custom header name and value
# - ecr: AWS ECR auto-refreshing token via the AWS SDK credential chain
auth:
# Example: npm with bearer token
# "https://registry.npmjs.org":
# type: bearer
# token: "${NPM_TOKEN}"
# Example: GitHub npm registry
# "https://npm.pkg.github.com":
# type: bearer
# token: "${GITHUB_TOKEN}"
# Example: PyPI with basic auth
# "https://pypi.org":
# type: basic
# username: "__token__"
# password: "${PYPI_TOKEN}"
# Example: Custom header for private registry
# "https://maven.mycompany.com":
# type: header
# header_name: "X-Auth-Token"
# header_value: "${MAVEN_TOKEN}"
# Example: private AWS ECR registry (12h tokens auto-refreshed via
# ecr:GetAuthorizationToken; credentials come from the AWS SDK default
# chain, so IRSA / instance profiles / AWS_* env vars all work; the region
# is inferred from the private ECR hostname)
# "https://123456789012.dkr.ecr.eu-west-1.amazonaws.com":
# type: ecr
# Gradle HttpBuildCache configuration
gradle:
build_cache:
# Set to true to disable PUT uploads (read-only cache mode)
read_only: false
# Maximum accepted Gradle cache upload body size
# Required and must be > 0
max_upload_size: "100MB"
# Evict entries older than this age (set to "0" to disable age-based eviction)
max_age: "168h"
# Cap total Gradle cache size; oldest entries are deleted first
# ("0" disables size-based eviction)
# max_size: "20GB"
# How often eviction runs when max_age or max_size is set
sweep_interval: "10m"
# Pushing package archives to /api/hosted (disabled by default).
# A pushed version replaces the upstream one for every client, so a token is
# required to push and delete.
# hosted:
# enabled: true
# token: "${PROXY_HOSTED_TOKEN}"
# max_upload_size: "100MB"
# # Namespaces served from pushed packages only; the upstream is never asked
# # about them, so nobody can mix in versions by publishing the same name.
# npm_scopes: ["@acme"]
# composer_vendors: ["acme"]
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 endpoint configuration.
health:
# Minimum time between storage backend probes.
# The /health endpoint runs a write/read/verify/delete round-trip
# against the configured storage backend and caches the result for
# this interval. Set to "0" to probe on every request.
# Default: "30s".
storage_probe_interval: "30s"
# Version cooldown configuration
# Hides package versions published too recently, giving the community time
# to spot malicious releases before they're pulled into projects.
# Supported durations: "7d" (days), "48h" (hours), "30m" (minutes), "0" (disabled)
cooldown:
# Global default cooldown for all ecosystems
# default: "3d"
# Per-ecosystem overrides
# ecosystems:
# npm: "7d"
# cargo: "0"
# Per-package overrides (keyed by PURL). Keys are normalized, so npm scopes
# may use either @scope or the canonical %40scope form.
# packages:
# "pkg:npm/lodash": "0"
# "pkg:npm/@babel/core": "14d"
Add pre-cache artifact scanning hook (trivy/ClamAV/Wiz/custom) (#298) Runs fetched artifacts through pluggable external scanners after they're staged in storage but before they're committed to the cache DB, so a block verdict deletes the object instead of ever exposing it to a client. Scanners pull the staged bytes themselves via a short-lived HMAC-signed internal route rather than the proxy pushing bytes to them, keeping the mechanism storage-backend-agnostic and avoiding uploading potentially huge artifacts through the proxy's own egress. Hardening baked in from the start: the internal scan-fetch route is gated both at router-mount time and in the handler so it's inert whenever scanning is disabled or unsigned; the signing key is mandatory whenever scanning is enabled, enforced directly in scanner.NewGroup rather than relying on callers to invoke config validation; the scan call and the delete-on-block cleanup both run on a context detached from the client's, so a client disconnecting mid-scan can't be mistaken for a scanner failure, doesn't cause a legitimate artifact to be deleted, and doesn't leave a genuinely blocked artifact's bytes orphaned in storage; and scanner infrastructure errors (connection failures, internal hostnames) are never forwarded verbatim to anonymous clients, only a generic message. The scan-error metric also correctly distinguishes a scanner's own timeout from being cancelled because a sibling scanner already decided the verdict. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 05:06:44 -04:00
# Per-package glob overrides, after exact packages and before ecosystems.
# package_patterns:
# "pkg:npm/@example/*": "0"
# Exact versions to deny, independently of cooldown and scanning.
# Metadata filtering: npm, PyPI and Cargo. Shared artifact downloads, including
# cache hits, are blocked with 403; signed APT metadata is left unchanged.
# Use versioned PURLs without qualifiers/subpaths. Restart after changes.
# denylist:
# packages:
# - "pkg:pypi/requests@2.31.0"
# - "pkg:cargo/some-crate@1.2.3"
# - "pkg:npm/%40scope/example@4.5.6"
Add pre-cache artifact scanning hook (trivy/ClamAV/Wiz/custom) (#298) Runs fetched artifacts through pluggable external scanners after they're staged in storage but before they're committed to the cache DB, so a block verdict deletes the object instead of ever exposing it to a client. Scanners pull the staged bytes themselves via a short-lived HMAC-signed internal route rather than the proxy pushing bytes to them, keeping the mechanism storage-backend-agnostic and avoiding uploading potentially huge artifacts through the proxy's own egress. Hardening baked in from the start: the internal scan-fetch route is gated both at router-mount time and in the handler so it's inert whenever scanning is disabled or unsigned; the signing key is mandatory whenever scanning is enabled, enforced directly in scanner.NewGroup rather than relying on callers to invoke config validation; the scan call and the delete-on-block cleanup both run on a context detached from the client's, so a client disconnecting mid-scan can't be mistaken for a scanner failure, doesn't cause a legitimate artifact to be deleted, and doesn't leave a genuinely blocked artifact's bytes orphaned in storage; and scanner infrastructure errors (connection failures, internal hostnames) are never forwarded verbatim to anonymous clients, only a generic message. The scan-error metric also correctly distinguishes a scanner's own timeout from being cancelled because a sibling scanner already decided the verdict. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 05:06:44 -04:00
# Pre-cache artifact scanning. When enabled, every artifact is staged into
# storage and scanned by the configured scanners before it is committed to
# the cache and served to clients. Scanners never receive artifact bytes
# directly — each notify call includes a short-lived signed URL that the
# scanner fetches itself, so the proxy stays agnostic to trivy/ClamAV/Wiz/
# any custom service. Scanners run concurrently; the first "block" verdict
# wins and cancels the rest.
# scanning:
# enabled: true
# fail_open: false
# timeout: 30s
#
# # Authenticates pull requests to the internal scan-fetch route.
# # Required whenever enabled is true. Supports ${VAR_NAME} expansion.
# signing_key: ${PROXY_SCANNING_SIGNING_KEY}
#
# # Address scanners use to reach this proxy to pull staged artifacts.
# # Defaults to base_url.
# # fetch_base_url: http://proxy.internal:8080
#
# scanners:
# - name: clamav
# url: http://clamav-adapter:8080/scan
# mode: block
# - name: trivy
# url: http://trivy-adapter:8081/scan
# mode: monitor
# ecosystems: [npm, pypi]