Watch
1
0
Fork
You've already forked pkg-proxy
1
mirror of https://github.com/git-pkgs/proxy.git synced 2026-11-09 23:59:16 -05:00
pkg-proxy/docs/architecture.md

435 lines
23 KiB
Markdown
Raw Permalink Normal View History

2026-01-20 21:52:44 +00:00
# Architecture
This document describes the internal architecture of the git-pkgs proxy.
## Overview
The proxy is a caching HTTP server that sits between package manager clients and upstream registries. It intercepts requests, checks a local cache, and either serves cached content or fetches from upstream.
```
┌──────────────────────────────────────────────────────────────────┐
│ HTTP Server │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Router (Chi) │ │
│ │ /npm/* -> NPMHandler /health -> healthHandler │ │
│ │ /cargo/* -> CargoHandler /stats -> statsHandler │ │
│ │ /gem/* -> GemHandler /metrics -> prometheus │ │
│ │ ...17 ecosystems /api/* -> APIHandler │ │
│ │ /ui/* -> Web UI │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
2026-01-20 21:52:44 +00:00
│ ┌───────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Database │ │ Storage │ │ Upstream │ │
│ │ SQLite or │ │ Filesystem │ │ Registries │ │
│ │ Postgres │ │ or S3 │ │ (Fetcher) │ │
2026-01-20 21:52:44 +00:00
│ └───────────┘ └─────────────┘ └─────────────┘ │
└──────────────────────────────────────────────────────────────────┘
2026-01-20 21:52:44 +00:00
```
## Request Flow
### Metadata Request (npm example)
1. Client requests `GET /npm/lodash`
2. NPMHandler receives request
3. Handler fetches metadata from upstream `registry.npmjs.org/lodash`
4. Handler rewrites tarball URLs in metadata to point at proxy, and filters versions withheld by cooldown or the denylist (`internal/metadata/npm`)
2026-01-20 21:52:44 +00:00
5. Handler returns modified metadata to client
Metadata is not cached - always fetched fresh. This ensures clients see new versions immediately.
### Artifact Download (npm example)
1. Client requests `GET /npm/lodash/-/lodash-4.17.21.tgz`
2. NPMHandler extracts package name and version from URL
3. Handler calls `Proxy.GetOrFetchArtifact()`
4. Proxy checks database for cached artifact:
**Cache Hit:**
- Look up artifact record in database
- Open file from storage
- Record hit (increment counter, update last_accessed_at)
- Return reader to handler
- Handler streams file to client, checking it against the recorded
SHA-256 `content_hash` (the registry's own integrity was checked when the
file was stored); a mismatch aborts the response and clears the record
2026-01-20 21:52:44 +00:00
**Cache Miss:**
- Resolve download URL using Resolver
- Fetch artifact from upstream using Fetcher
- Copy the body to Storage and, for a client GET without a `Range`
header, to the client at once (`storeWhileServing`); the client's copy
is checked against the upstream's digest as it is read, and a mismatch
aborts it. A block-mode scanner, `HEAD` and range requests, and
requests joining a fetch already running wait for the stored file.
2026-01-20 21:52:44 +00:00
- Store artifact in Storage (returns size, hash)
- Create/update database records (package, version, artifact)
- Requests that waited open the stored file and stream it to their client
2026-01-20 21:52:44 +00:00
Support HTTP byte-range requests for local file cached artifact downloads (#404) * feat(storage): expose efficient seek for local files Open file-backed artifacts with os.Open so local readers support efficient seeking. Leave cloud storage readers unchanged. Keep the legacy sidecar mapping test focused on fileblob's reader: Blob.Open now reads local files directly and no longer consults the sidecar. Continue checking sidecar cleanup and post-store reads. * feat(handler): preserve efficient seek through integrity checks Keep the cache result's single Reader field and preserve io.Seeker when the underlying storage reader supports efficient seeking. Sequential reads continue through whole-object integrity verification. A successful seek switches subsequent reads to the source for range responses, where full-object verification is not possible. Test both modes and verify the source is closed once. * feat(handler): add request-aware byte range responses Add a request-aware artifact serving helper for bounded, open-ended, and suffix byte ranges, including If-Range handling, 206 responses, and 416 responses for valid unsatisfiable ranges. Ignore malformed and multi-range requests by serving the full artifact. Advertise byte-range support only for seekable readers, preserving HEAD, redirect, and non-seekable behavior. Clarify that net/http recovers ErrAbortHandler per request and keeps the server running. * feat(handler): enable byte ranges across artifact downloads Forward each artifact request to the shared range-aware response helper, including cached generic release assets and OCI blobs. This lets the handler honor ranges only when its reader supports efficient seeking, without changing direct-storage redirect behavior. Add warm-cache endpoint tests that assert partial response headers and bytes without an upstream refetch. Document supported range behavior, unsupported reader fallback, and the integrity limitation of partial reads.
2026-10-04 09:48:41 -07:00
Artifact handlers honor single byte ranges when the storage reader supports
efficient seeking (currently the local filesystem reader). They advertise
`Accept-Ranges: bytes`, return `206` or `416` as appropriate, and respect
`If-Range`. Malformed and multi-range requests fall back to the full response.
Non-seekable storage readers do not advertise range support, and direct-storage
redirects rely on the destination's capabilities. Range responses cannot verify
the full artifact digest because they read only part of the artifact.
2026-01-20 21:52:44 +00:00
```
┌────────┐ GET /npm/lodash/-/lodash-4.17.21.tgz ┌─────────────┐
│ Client │ ──────────────────────────────────────▶│ NPMHandler │
└────────┘ └──────┬──────┘
│
▼
┌─────────────────┐
│ Proxy │
│ GetOrFetch │
└────────┬────────┘
│
┌───────────────────┼───────────────────┐
│ │ │
▼ ▼ ▼
┌───────────┐ ┌───────────┐ ┌───────────┐
│ Database │ │ Storage │ │ Upstream │
│ (lookup) │ │ (read) │ │ (fetch) │
└───────────┘ └───────────┘ └───────────┘
```
## Package Structure
### `internal/database`
SQLite or PostgreSQL database for cache metadata. SQLite uses `modernc.org/sqlite` (pure Go, no CGO). PostgreSQL uses `lib/pq`.
For SQLite, `DB` holds two pools: one connection for writes and a pool of read-only (`query_only`) connections for reads. `Get`, `Select`, `Query` and `QueryRow` go to the reader; `Exec`, transactions and everything else go to the writer, so a read inside a transaction sees the transaction's writes. A write sent to the reader fails instead of running. WAL mode lets the readers run while the writer commits. PostgreSQL and in-memory SQLite use one pool for both.
The schema is compatible with [git-pkgs](https://github.com/git-pkgs) databases. The proxy adds the `artifacts` and `vulnerabilities` tables on top of the shared `packages` and `versions` tables, so both tools can point at the same database.
2026-01-20 21:52:44 +00:00
**Tables:**
```sql
packages (
id INTEGER PRIMARY KEY, -- SERIAL on Postgres
purl TEXT NOT NULL, -- unique, e.g. pkg:npm/lodash
ecosystem TEXT NOT NULL,
name TEXT NOT NULL,
latest_version TEXT,
license TEXT,
description TEXT,
homepage TEXT,
repository_url TEXT,
registry_url TEXT,
supplier_name TEXT,
supplier_type TEXT,
source TEXT,
enriched_at DATETIME,
vulns_synced_at DATETIME,
created_at DATETIME,
updated_at DATETIME
2026-01-20 21:52:44 +00:00
)
-- indexes: purl (unique), (ecosystem, name)
2026-01-20 21:52:44 +00:00
versions (
id INTEGER PRIMARY KEY,
purl TEXT NOT NULL, -- unique, e.g. pkg:npm/lodash@4.17.21
package_purl TEXT NOT NULL, -- FK to packages.purl
license TEXT,
published_at DATETIME,
integrity TEXT, -- subresource integrity hash
yanked INTEGER DEFAULT 0, -- BOOLEAN on Postgres
source TEXT,
enriched_at DATETIME,
created_at DATETIME,
updated_at DATETIME
2026-01-20 21:52:44 +00:00
)
-- indexes: purl (unique), package_purl
2026-01-20 21:52:44 +00:00
artifacts (
id INTEGER PRIMARY KEY,
version_purl TEXT NOT NULL,
filename TEXT NOT NULL,
upstream_url TEXT NOT NULL,
storage_path TEXT, -- null until cached
content_hash TEXT, -- SHA-256
size INTEGER, -- BIGINT on Postgres
content_type TEXT,
fetched_at DATETIME,
hit_count INTEGER DEFAULT 0, -- BIGINT on Postgres
last_accessed_at DATETIME,
created_at DATETIME,
updated_at DATETIME
)
-- indexes: (version_purl, filename) unique, storage_path, last_accessed_at
pending_deletes (
path TEXT NOT NULL PRIMARY KEY, -- storage path no record points at
queued_at DATETIME NOT NULL
)
vulnerabilities (
id INTEGER PRIMARY KEY,
vuln_id TEXT NOT NULL, -- e.g. CVE-2021-1234
ecosystem TEXT NOT NULL,
package_name TEXT NOT NULL,
severity TEXT,
summary TEXT,
fixed_version TEXT,
cvss_score REAL,
"references" TEXT, -- JSON array
fetched_at DATETIME,
created_at DATETIME,
updated_at DATETIME
2026-01-20 21:52:44 +00:00
)
-- indexes: (vuln_id, ecosystem, package_name) unique, (ecosystem, package_name)
metadata_cache (
id INTEGER PRIMARY KEY,
ecosystem TEXT NOT NULL,
name TEXT NOT NULL,
storage_path TEXT NOT NULL,
etag TEXT,
content_type TEXT,
fix(handler): preserve upstream content-encoding for cached metadata (#304) * fix(handler): preserve upstream content-encoding for cached metadata Signed and hash-pinned index files (debian Release/Packages.gz, rpm repomd.xml, helm index.yaml, conda repodata.json, apk APKINDEX.tar.gz) were cached after Go's transport transparently decompressed any Content-Encoding: gzip response, so the proxy served bytes that differ from what the upstream signed and broke client verification. Request the identity encoding on the shared metadata fetch so Go no longer auto-decompresses, and persist the upstream Content-Encoding in a new metadata_cache column so both the cached and offline responses replay the exact bytes and header. The uncached streaming path now forwards Content-Encoding too. Fixes #300 * fix(handler): scope verbatim metadata fetch to the ProxyCached path The first cut forced Accept-Encoding: identity in the shared fetchUpstreamMetadata, which an adversarial review showed both missed the bug and regressed unrelated ecosystems: - proxyMetadataStream (the default path, since cache_metadata is off) never forced identity, so a client sending no Accept-Encoding still triggered Go's transparent gzip decompression and served altered bytes for apk/debian/rpm/conda indexes. - Direct FetchOrCacheMetadata callers that parse or rewrite the body (npm, pypi, cargo, composer, pub, nuget, swift, maven, helm) were forced to identity too, losing wire compression and 502-ing against upstreams that ignore identity and gzip anyway (helm rewriteIndex). - Rows cached before the fix kept serving decompressed bytes via ETag 304 revalidation. Scope the verbatim behavior to the ProxyCached code path, which serves upstream bytes through unchanged (apk, debian, rpm, go, hex, conda, cran, gem, conan, julia). That path now requests identity on both the cached fetch and the uncached stream branch and replays Content-Encoding; direct callers keep transparent compression, matching main. Migration 008 clears etag/fetched_at so legacy rows refetch once with identity. Tests now exercise the stream path with no client Accept-Encoding and pin that direct callers are not forced to identity.
2026-09-03 10:58:58 +02:00
content_encoding TEXT, -- replayed on serve so signed bytes stay verbatim
size INTEGER, -- BIGINT on Postgres
fetched_at DATETIME,
created_at DATETIME,
updated_at DATETIME
)
-- indexes: (ecosystem, name) unique
metadata_dates (
ecosystem TEXT NOT NULL,
proxy_url TEXT NOT NULL, -- each proxy URL rewrites to its own download URLs
name TEXT NOT NULL,
etag TEXT NOT NULL, -- the rewritten representation being dated
last_modified INTEGER NOT NULL, -- Unix seconds; BIGINT on Postgres
PRIMARY KEY (ecosystem, proxy_url, name)
)
2026-01-20 21:52:44 +00:00
```
`metadata_dates` holds the `Last-Modified` served with rewritten metadata, such as npm packuments, Composer documents, PyPI simple pages and Helm indexes. A package's date is the second it started serving its current representation, and a new representation is only dated in a later second. Keeping it in the database means replicas sharing one serve the same date, and never give one representation's date to another, so `If-Modified-Since` cannot return 304 for a body the client does not have.
On PostgreSQL, `INTEGER PRIMARY KEY` becomes `SERIAL`, `DATETIME` becomes `TIMESTAMP`, `INTEGER DEFAULT 0` booleans become `BOOLEAN DEFAULT FALSE`, and size/count columns use `BIGINT`.
The `MigrateSchema()` function handles backward compatibility with older git-pkgs databases by running named migrations that add missing columns and tables. See [migrations.md](migrations.md) for how to add new schema changes.
2026-01-20 21:52:44 +00:00
**Key operations:**
- `GetPackageByPURL()` - Look up package by PURL
- `GetVersionByPURL()` - Look up version by PURL
- `GetArtifact()` - Look up artifact by version + filename
- `UpsertPackage/Version/Artifact()` - Insert or update records
- `RecordArtifactHit()` - Increment hit counter, update access time
- `GetLeastRecentlyUsedArtifacts()` - For cache eviction
- `SearchPackages()` - Full-text search across cached packages
2026-01-20 21:52:44 +00:00
### `internal/storage`
File storage abstraction. Current implementation uses local filesystem.
**Interface:**
```go
type Storage interface {
Store(ctx, path, reader) (size, hash, error)
Open(ctx, path) (io.ReadCloser, error)
Exists(ctx, path) (bool, error)
Delete(ctx, path) error
Size(ctx, path) (int64, error)
UsedSpace(ctx) (int64, error)
}
```
**Filesystem implementation:**
- Stores files in nested directories: `{ecosystem}/{name}/{version}/{fetch id}/{filename}`, a new id per fetch, so fetches of one artifact never overwrite or delete each other's object (artifacts cached before this sit at `{ecosystem}/{name}/{version}/{filename}`)
2026-01-20 21:52:44 +00:00
- Atomic writes using temp file + rename
- Computes SHA256 hash during write
- Removes a fetch's directory once its object is deleted
2026-01-20 21:52:44 +00:00
**Path structure:**
```
cache/artifacts/
├── npm/
│ ├── lodash/
│ │ └── 4.17.21/
│ │ └── 3f9a0c1d2e4b5a67/
│ │ └── lodash-4.17.21.tgz
2026-01-20 21:52:44 +00:00
│ └── @babel/
│ └── core/
│ └── 7.23.0/
│ └── 8c2e41f09a7d3b15/
│ └── core-7.23.0.tgz
2026-01-20 21:52:44 +00:00
└── cargo/
└── serde/
└── 1.0.193/
└── d05b7e9c14a2f863/
└── serde-1.0.193.crate
2026-01-20 21:52:44 +00:00
```
### `internal/upstream`
Fetches artifacts from upstream registries.
**Fetcher:**
- HTTP client with configurable timeout (5 min default for large artifacts)
- Exponential backoff retry on 429 (rate limit) and 5xx errors
- Returns streaming reader (doesn't load into memory)
- Configurable user-agent
- Shares an authentication-aware transport with metadata requests so URL-scoped credentials apply consistently
- Discovers and caches scoped OCI Bearer tokens from registry challenges
2026-01-20 21:52:44 +00:00
**Resolver:**
- Determines download URL for a package/version
- Handles ecosystem-specific URL patterns:
- npm: `https://registry.npmjs.org/{name}/-/{shortname}-{version}.tgz`
- cargo: `https://static.crates.io/crates/{name}/{name}-{version}.crate`
- etc.
### `internal/handler`
HTTP protocol handlers for each registry type.
**Proxy (shared):**
- `GetOrFetchArtifact()` - Main cache logic
- Coordinates database, storage, and fetcher
- Handles cache hit/miss flow
**NPMHandler:**
- `handlePackageMetadata()` - Proxy + rewrite metadata
- `handleDownload()` - Serve cached artifact
- Fetches, caches and serves packuments, and caches their rewrites; the rewrite itself is `internal/metadata/npm`
2026-01-20 21:52:44 +00:00
**CargoHandler:**
- `handleConfig()` - Return registry config
- `handleIndex()` - Proxy sparse index
- `handleDownload()` - Serve cached crate
**SwiftHandler:**
- Proxies the Swift Package Registry v1 read endpoints
- Rewrites release URLs and caches source archives
- With `cache_metadata`, caches release metadata, release lists and manifests (with their `Link` header) and serves them when the upstream fails
Serve Git repositories from a local cache (#492) * Serve Git repositories from a local cache Named upstream.git hosts are served read-only over Git's smart HTTP protocol at /git/{name}/{repo}, so git clone and git fetch work against the proxy through an insteadOf rewrite. Each repository is a bare clone at git.cache_dir/{name}-{hash of the upstream URL}/{repo}.git, served by git upload-pack. Every request for refs checks the upstream's branches, tags and HEAD with a protocol v2 ls-refs limited to those prefixes, and fetches only when they changed, atomically and with --prune. Concurrent requests share one refresh. Only branches and tags are cached, and HEAD follows the upstream's default branch. A commit a client asks for by ID that the cache lacks, such as a pull request head, is fetched and kept under a hidden ref until a branch or tag includes it or for 7 days. One the upstream cannot provide is not asked for again for a minute, or an hour when it is larger than git.max_repo_size leaves. This is off for upstreams upstream.auth has credentials for, since GitHub serves commits from any repository in a fork network. git never reaches an upstream itself. Its fetches go through a loopback relay that forwards with the proxy's upstream client, so address checks, upstream.auth and the access log apply, and git never sees a credential. Redirects are not followed, and git runs with an environment of its own. The cached copy is served while the upstream fails or is slower than git.refresh_wait, until git.stale_max_age has passed since the last successful check. Refusals and missing repositories are never answered from the cache. git.max_repo_size cuts a transfer off as it streams, and every fetched pack is kept as a pack, so a cut-off leaves nothing behind. A repository that can no longer be refreshed within the limit is cloned again with only its branches and tags. git.max_size evicts the least recently used repositories, never one in use. At startup only the directory of a repointed upstream's old URL is removed, and Close waits for refreshes in progress. Hosts with upstream.auth credentials need a repos allowlist, and a repository outside it answers 404. Refs #403 * Document setting the Git rewrite through environment variables The Git section of the configuration docs now explains what insteadOf does, with the URL a clone fetches from, and shows the same rewrites set through GIT_CONFIG_COUNT, GIT_CONFIG_KEY_n and GIT_CONFIG_VALUE_n for CI. Package managers that run git for Git dependencies inherit those variables, so their fetches are cached too; npm's GitHub tarball downloads do not go through git and are not. Refs #403 * Note that upstream.auth credentials reach every route to a host Credentials are matched by URL, so a token configured for a Git upstream is also sent by a generic upstream at the same URL, which forwards any path and checks no repos allowlist. With a token for a private GitHub org, /generic/github/acme/private.git/info/refs would list that repository's refs to anyone who can reach the proxy. * Cap concurrent upload-pack processes and request body size Every POST git-upload-pack started a pack-objects with git's defaults, a delta-search thread per CPU and unbounded window memory, and nothing limited how many ran at once, so a burst of CI clones of one large repository could run the proxy out of memory. At most two upload-pack processes per CPU (GOMAXPROCS, so a container's limit) now build packs at once, and further requests wait for a slot while the client is connected. ls-refs builds no pack and does not wait. Serving runs with pack.threads=1 and pack.windowMemory=64m, and every git process with core.bigFileThreshold=8m, so index-pack streams large files instead of holding them in memory. A request body was streamed to upload-pack unbounded, so a small gzip body could feed it an arbitrarily long negotiation for as long as the request may run. Bodies are now limited to 100MB, both as received and decoded, and one past the limit fails with an error instead of being cut short quietly. * Apply refresh_wait to the ref check only refresh_wait cut off the whole refresh, fetch included, so after a push with a large pack a client got the old refs once it passed, though the upstream was fine and the docs say a clone right after a push sees the new commit. It now bounds only the check of the upstream's branches and tags. Once the check finds they changed, requests wait for the fetch; one that fails, or stalls and is abandoned by http.lowSpeedTime, still falls back to the cached copy within stale_max_age. * Lock cache_dir against a second process The cache clears its temporary directory at startup, so a second process given the same cache_dir, such as a second pod on a shared volume, would delete the first one's clones in progress, and each process's eviction knows only its own readers. New now locks {cache_dir}/.gitcache-lock before touching anything and holds it until Close: flock on Unix, a handle opened without sharing on Windows. The OS releases it when the process exits, so a crashed process can restart. A second process fails to start with an error naming the directory. * Evict again once repositories in use are released Eviction skips repositories a request or refresh is using, and only the release of one too large to refresh ran it again, so a cache filled past max_size while its repositories were in use stayed over it once they went idle, until the next fetch. Eviction now records when it is left over max_size, and the next release runs it again. * Cap git.max_repo_size at git.max_size A repository larger than max_size could still be cloned, up to max_repo_size, which defaults to 5GB whatever max_size is. Once released it was evicted to bring the cache back within max_size, and the next request cloned it again. max_size now caps max_repo_size, so such a repository is refused for an hour like any other too large one, with an error that names both limits. * Count fetches in progress toward git.max_size Fetches enforced only max_repo_size, and eviction ran after each one, so up to 16 concurrent fetches could take the cache past max_size by as much as each may download. Each fetch now reserves space within max_size as the relay receives its bytes, a chunk at a time, evicting least recently used idle repositories to make room. When the fetch ends, the repository's measured size replaces the reservation under the cache lock, so its bytes are neither counted twice nor missed. When evicting every idle repository would not make room, none is evicted for the fetch, and it is cut off with ErrCacheFull rather than ErrTooLarge, so the repository is not refused for an hour: a clone answers 507, and a refresh serves the cached copy as when the upstream fails. A fetch that runs out of room while another has just been cut off waits, up to a minute, for that one's space, so two fetches that do not fit together do not both fail. A fetch that was cut off never waits, so no two wait on each other. --------- Co-authored-by: Andrew Nesbitt <andrewnez@gmail.com>
2026-10-21 03:47:16 -07:00
**GitHandler:**
- Serves `/git/{name}/{repo}` from `internal/gitcache`, read-only over smart HTTP
- Validates repository paths and the `repos` allowlist, and maps cache errors to statuses
- Serves the Git LFS API under `{repo}.git/info/lfs/`: forwards download batches upstream and points each object at `objects/{oid}/{size}`, which is served from the artifact cache (ecosystem `git`, name `{name}/{repo}`, version the object ID); a miss uses the download URL kept from the batch or asks the upstream batch API for a fresh one, recorded without its signed query, copies the object from another repository when its content is already stored, and checks the object against its sha256 ID; a batch the upstream can't answer is answered from the cache
Serve Git repositories from a local cache (#492) * Serve Git repositories from a local cache Named upstream.git hosts are served read-only over Git's smart HTTP protocol at /git/{name}/{repo}, so git clone and git fetch work against the proxy through an insteadOf rewrite. Each repository is a bare clone at git.cache_dir/{name}-{hash of the upstream URL}/{repo}.git, served by git upload-pack. Every request for refs checks the upstream's branches, tags and HEAD with a protocol v2 ls-refs limited to those prefixes, and fetches only when they changed, atomically and with --prune. Concurrent requests share one refresh. Only branches and tags are cached, and HEAD follows the upstream's default branch. A commit a client asks for by ID that the cache lacks, such as a pull request head, is fetched and kept under a hidden ref until a branch or tag includes it or for 7 days. One the upstream cannot provide is not asked for again for a minute, or an hour when it is larger than git.max_repo_size leaves. This is off for upstreams upstream.auth has credentials for, since GitHub serves commits from any repository in a fork network. git never reaches an upstream itself. Its fetches go through a loopback relay that forwards with the proxy's upstream client, so address checks, upstream.auth and the access log apply, and git never sees a credential. Redirects are not followed, and git runs with an environment of its own. The cached copy is served while the upstream fails or is slower than git.refresh_wait, until git.stale_max_age has passed since the last successful check. Refusals and missing repositories are never answered from the cache. git.max_repo_size cuts a transfer off as it streams, and every fetched pack is kept as a pack, so a cut-off leaves nothing behind. A repository that can no longer be refreshed within the limit is cloned again with only its branches and tags. git.max_size evicts the least recently used repositories, never one in use. At startup only the directory of a repointed upstream's old URL is removed, and Close waits for refreshes in progress. Hosts with upstream.auth credentials need a repos allowlist, and a repository outside it answers 404. Refs #403 * Document setting the Git rewrite through environment variables The Git section of the configuration docs now explains what insteadOf does, with the URL a clone fetches from, and shows the same rewrites set through GIT_CONFIG_COUNT, GIT_CONFIG_KEY_n and GIT_CONFIG_VALUE_n for CI. Package managers that run git for Git dependencies inherit those variables, so their fetches are cached too; npm's GitHub tarball downloads do not go through git and are not. Refs #403 * Note that upstream.auth credentials reach every route to a host Credentials are matched by URL, so a token configured for a Git upstream is also sent by a generic upstream at the same URL, which forwards any path and checks no repos allowlist. With a token for a private GitHub org, /generic/github/acme/private.git/info/refs would list that repository's refs to anyone who can reach the proxy. * Cap concurrent upload-pack processes and request body size Every POST git-upload-pack started a pack-objects with git's defaults, a delta-search thread per CPU and unbounded window memory, and nothing limited how many ran at once, so a burst of CI clones of one large repository could run the proxy out of memory. At most two upload-pack processes per CPU (GOMAXPROCS, so a container's limit) now build packs at once, and further requests wait for a slot while the client is connected. ls-refs builds no pack and does not wait. Serving runs with pack.threads=1 and pack.windowMemory=64m, and every git process with core.bigFileThreshold=8m, so index-pack streams large files instead of holding them in memory. A request body was streamed to upload-pack unbounded, so a small gzip body could feed it an arbitrarily long negotiation for as long as the request may run. Bodies are now limited to 100MB, both as received and decoded, and one past the limit fails with an error instead of being cut short quietly. * Apply refresh_wait to the ref check only refresh_wait cut off the whole refresh, fetch included, so after a push with a large pack a client got the old refs once it passed, though the upstream was fine and the docs say a clone right after a push sees the new commit. It now bounds only the check of the upstream's branches and tags. Once the check finds they changed, requests wait for the fetch; one that fails, or stalls and is abandoned by http.lowSpeedTime, still falls back to the cached copy within stale_max_age. * Lock cache_dir against a second process The cache clears its temporary directory at startup, so a second process given the same cache_dir, such as a second pod on a shared volume, would delete the first one's clones in progress, and each process's eviction knows only its own readers. New now locks {cache_dir}/.gitcache-lock before touching anything and holds it until Close: flock on Unix, a handle opened without sharing on Windows. The OS releases it when the process exits, so a crashed process can restart. A second process fails to start with an error naming the directory. * Evict again once repositories in use are released Eviction skips repositories a request or refresh is using, and only the release of one too large to refresh ran it again, so a cache filled past max_size while its repositories were in use stayed over it once they went idle, until the next fetch. Eviction now records when it is left over max_size, and the next release runs it again. * Cap git.max_repo_size at git.max_size A repository larger than max_size could still be cloned, up to max_repo_size, which defaults to 5GB whatever max_size is. Once released it was evicted to bring the cache back within max_size, and the next request cloned it again. max_size now caps max_repo_size, so such a repository is refused for an hour like any other too large one, with an error that names both limits. * Count fetches in progress toward git.max_size Fetches enforced only max_repo_size, and eviction ran after each one, so up to 16 concurrent fetches could take the cache past max_size by as much as each may download. Each fetch now reserves space within max_size as the relay receives its bytes, a chunk at a time, evicting least recently used idle repositories to make room. When the fetch ends, the repository's measured size replaces the reservation under the cache lock, so its bytes are neither counted twice nor missed. When evicting every idle repository would not make room, none is evicted for the fetch, and it is cut off with ErrCacheFull rather than ErrTooLarge, so the repository is not refused for an hour: a clone answers 507, and a refresh serves the cached copy as when the upstream fails. A fetch that runs out of room while another has just been cut off waits, up to a minute, for that one's space, so two fetches that do not fit together do not both fail. A fetch that was cut off never waits, so no two wait on each other. --------- Co-authored-by: Andrew Nesbitt <andrewnez@gmail.com>
2026-10-21 03:47:16 -07:00
### `internal/metadata/npm`
The npm metadata rewrite, separated from the handler so it can run where the proxy's infrastructure can't, such as WASI. It takes a packument's bytes and everything else as explicit inputs (proxy URL, cooldown policy, the time cooldown is evaluated at, denied versions, pushed versions) and returns the rewritten document with the versions it kept. It filters versions by cooldown and the denylist, moves `latest` off a withheld version, lists pushed versions in place of upstream ones, and points each tarball URL at the proxy, copying everything else byte for byte. It does no I/O and never reads the clock; callbacks report what it did, which the handler logs. It imports no database, storage, metrics, HTTP or logging packages, which a test checks.
### `internal/jsonscan`
Reads and rewrites members of a JSON document in place, by byte offsets, without decoding the whole document. Shared by the npm and Composer metadata rewrites.
Serve Git repositories from a local cache (#492) * Serve Git repositories from a local cache Named upstream.git hosts are served read-only over Git's smart HTTP protocol at /git/{name}/{repo}, so git clone and git fetch work against the proxy through an insteadOf rewrite. Each repository is a bare clone at git.cache_dir/{name}-{hash of the upstream URL}/{repo}.git, served by git upload-pack. Every request for refs checks the upstream's branches, tags and HEAD with a protocol v2 ls-refs limited to those prefixes, and fetches only when they changed, atomically and with --prune. Concurrent requests share one refresh. Only branches and tags are cached, and HEAD follows the upstream's default branch. A commit a client asks for by ID that the cache lacks, such as a pull request head, is fetched and kept under a hidden ref until a branch or tag includes it or for 7 days. One the upstream cannot provide is not asked for again for a minute, or an hour when it is larger than git.max_repo_size leaves. This is off for upstreams upstream.auth has credentials for, since GitHub serves commits from any repository in a fork network. git never reaches an upstream itself. Its fetches go through a loopback relay that forwards with the proxy's upstream client, so address checks, upstream.auth and the access log apply, and git never sees a credential. Redirects are not followed, and git runs with an environment of its own. The cached copy is served while the upstream fails or is slower than git.refresh_wait, until git.stale_max_age has passed since the last successful check. Refusals and missing repositories are never answered from the cache. git.max_repo_size cuts a transfer off as it streams, and every fetched pack is kept as a pack, so a cut-off leaves nothing behind. A repository that can no longer be refreshed within the limit is cloned again with only its branches and tags. git.max_size evicts the least recently used repositories, never one in use. At startup only the directory of a repointed upstream's old URL is removed, and Close waits for refreshes in progress. Hosts with upstream.auth credentials need a repos allowlist, and a repository outside it answers 404. Refs #403 * Document setting the Git rewrite through environment variables The Git section of the configuration docs now explains what insteadOf does, with the URL a clone fetches from, and shows the same rewrites set through GIT_CONFIG_COUNT, GIT_CONFIG_KEY_n and GIT_CONFIG_VALUE_n for CI. Package managers that run git for Git dependencies inherit those variables, so their fetches are cached too; npm's GitHub tarball downloads do not go through git and are not. Refs #403 * Note that upstream.auth credentials reach every route to a host Credentials are matched by URL, so a token configured for a Git upstream is also sent by a generic upstream at the same URL, which forwards any path and checks no repos allowlist. With a token for a private GitHub org, /generic/github/acme/private.git/info/refs would list that repository's refs to anyone who can reach the proxy. * Cap concurrent upload-pack processes and request body size Every POST git-upload-pack started a pack-objects with git's defaults, a delta-search thread per CPU and unbounded window memory, and nothing limited how many ran at once, so a burst of CI clones of one large repository could run the proxy out of memory. At most two upload-pack processes per CPU (GOMAXPROCS, so a container's limit) now build packs at once, and further requests wait for a slot while the client is connected. ls-refs builds no pack and does not wait. Serving runs with pack.threads=1 and pack.windowMemory=64m, and every git process with core.bigFileThreshold=8m, so index-pack streams large files instead of holding them in memory. A request body was streamed to upload-pack unbounded, so a small gzip body could feed it an arbitrarily long negotiation for as long as the request may run. Bodies are now limited to 100MB, both as received and decoded, and one past the limit fails with an error instead of being cut short quietly. * Apply refresh_wait to the ref check only refresh_wait cut off the whole refresh, fetch included, so after a push with a large pack a client got the old refs once it passed, though the upstream was fine and the docs say a clone right after a push sees the new commit. It now bounds only the check of the upstream's branches and tags. Once the check finds they changed, requests wait for the fetch; one that fails, or stalls and is abandoned by http.lowSpeedTime, still falls back to the cached copy within stale_max_age. * Lock cache_dir against a second process The cache clears its temporary directory at startup, so a second process given the same cache_dir, such as a second pod on a shared volume, would delete the first one's clones in progress, and each process's eviction knows only its own readers. New now locks {cache_dir}/.gitcache-lock before touching anything and holds it until Close: flock on Unix, a handle opened without sharing on Windows. The OS releases it when the process exits, so a crashed process can restart. A second process fails to start with an error naming the directory. * Evict again once repositories in use are released Eviction skips repositories a request or refresh is using, and only the release of one too large to refresh ran it again, so a cache filled past max_size while its repositories were in use stayed over it once they went idle, until the next fetch. Eviction now records when it is left over max_size, and the next release runs it again. * Cap git.max_repo_size at git.max_size A repository larger than max_size could still be cloned, up to max_repo_size, which defaults to 5GB whatever max_size is. Once released it was evicted to bring the cache back within max_size, and the next request cloned it again. max_size now caps max_repo_size, so such a repository is refused for an hour like any other too large one, with an error that names both limits. * Count fetches in progress toward git.max_size Fetches enforced only max_repo_size, and eviction ran after each one, so up to 16 concurrent fetches could take the cache past max_size by as much as each may download. Each fetch now reserves space within max_size as the relay receives its bytes, a chunk at a time, evicting least recently used idle repositories to make room. When the fetch ends, the repository's measured size replaces the reservation under the cache lock, so its bytes are neither counted twice nor missed. When evicting every idle repository would not make room, none is evicted for the fetch, and it is cut off with ErrCacheFull rather than ErrTooLarge, so the repository is not refused for an hour: a clone answers 507, and a refresh serves the cached copy as when the upstream fails. A fetch that runs out of room while another has just been cut off waits, up to a minute, for that one's space, so two fetches that do not fit together do not both fail. A fetch that was cut off never waits, so no two wait on each other. --------- Co-authored-by: Andrew Nesbitt <andrewnez@gmail.com>
2026-10-21 03:47:16 -07:00
### `internal/gitcache`
Local bare clones of upstream Git repositories, outside the artifact cache and the database, at `{git.cache_dir}/{name}-{URL hash}/{repo}.git`. The hash of the upstream's URL means repointing an upstream starts a fresh cache; at startup only the configured upstreams' directories are loaded, and those of their old URLs removed.
- Each request for refs checks the upstream's branches and tags with protocol v2 `ls-refs` and fetches only when they changed; concurrent requests share one refresh
- Clients are served by `git upload-pack --stateless-rpc`
- Spawned `git` reaches the upstream only through a loopback relay that forwards with the proxy's upstream client, so address checks, `upstream.auth` and the access log apply
- Commits a client asks for by ID that are on no cached branch or tag are fetched and kept under hidden `refs/proxy/wanted/{time}/{id}` refs until a branch or tag includes them or for 7 days; off for upstreams with credentials
- Evicts least recently used repositories after each fetch once `git.max_size` is passed, and drops a repository that can no longer be refreshed within `git.max_repo_size` so it is cloned again; never one in use. A commit fetched by ID that does not fit is refused instead
- Every fetched pack is kept as a pack (`transfer.unpackLimit=1`), so a transfer cut off at the size limit leaves nothing behind
2026-01-20 21:52:44 +00:00
### `internal/server`
HTTP server setup, web UI, and API handlers.
2026-01-20 21:52:44 +00:00
- Creates and wires together all components
- Mounts protocol handlers at ecosystem-specific paths
- Middleware: request ID, real IP, logging, panic recovery, active request tracking
- Web UI under `/ui`: dashboard, package browser, source browser, version comparison
- Templates are embedded in the binary via `//go:embed`
- Enrichment API for package metadata, vulnerability scanning, and outdated detection
- Health, stats, and Prometheus metrics endpoints. `/health` runs an active write → size-check → read → verify → delete probe against the storage backend and returns a structured JSON response (`HealthResponse`) with `"ok"` / `"error"` status per subsystem. Probe results are cached (default 30 s, configurable via `health.storage_probe_interval`) to avoid overwhelming remote backends. The response also carries a `circuit_breakers` map reporting each upstream's artifact-fetch breaker as `"open"` or `"closed"`, keyed by the host fetched from (or an opaque placeholder where the fetch URL has no host to read); the same state is published as the `proxy_circuit_breaker_state` gauge on each `/metrics` scrape. An open breaker leaves the overall status `"ok"` — it describes an upstream, not this proxy.
### `internal/memlimit`
Sets Go's soft memory limit to 80% of the cgroup memory limit at startup on Linux, unless `GOMEMLIMIT` is set. See "Memory limit" in the configuration docs.
### `internal/metrics`
Prometheus metrics for cache performance, upstream latency, storage operations, and active requests. See the Monitoring section of the README for the full metric list.
### Cooldown
Version age filtering for supply chain attack mitigation, provided by [github.com/git-pkgs/cooldown](https://github.com/git-pkgs/cooldown). Configurable at global, ecosystem, and per-package levels. Supported by the npm, PyPI, pub.dev, Composer, Cargo, NuGet, Conda, RubyGems, Hex, Go, Maven and Helm handlers.
### `internal/enrichment`
Package metadata enrichment, built on [github.com/git-pkgs/enrichment](https://github.com/git-pkgs/enrichment). Fetches license, description, homepage, repository URL, and vulnerability data from upstream registries. Powers the `/api/` endpoints and the web UI's package detail pages.
2026-01-20 21:52:44 +00:00
### `internal/mirror`
Selective package mirroring for pre-populating the proxy cache. Supports multiple input sources: individual PURLs (versioned or unversioned), CycloneDX/SPDX SBOM files, and full registry enumeration. Uses a bounded worker pool backed by `errgroup` to download artifacts in parallel, reusing `handler.Proxy.GetOrFetchArtifact()` for the actual fetch-and-cache work. Container images (`pkg:docker`, `pkg:oci`) go through `handler.ContainerHandler.MirrorImage()` instead, which walks index, platform manifests and blobs and stores them under the keys a pull reads.
The package also provides a `MetadataCache` for storing raw upstream metadata blobs so the proxy can serve metadata responses offline. The `JobStore` manages async mirror jobs exposed via the `/api/mirror` endpoints.
2026-01-20 21:52:44 +00:00
### `internal/config`
Configuration loading.
- Supports YAML and JSON files
- Environment variable overrides (PROXY_ prefix)
- Command line flag overrides
- Validation
## Extending the Proxy
### Adding a New Registry
1. Add URL resolution in `upstream/resolver.go`
2. Create handler in `handler/newregistry.go`
3. Mount in `server/server.go`
4. Add tests
### Adding a New Storage Backend
1. Implement `storage.Storage` interface
2. Add configuration options in `config/config.go`
3. Add initialization in `server/server.go`
### Cache Eviction
The database tracks `hit_count` and `last_accessed_at` for LRU eviction. Query with:
```go
db.GetLeastRecentlyUsedArtifacts(limit)
```
Eviction can be implemented as:
1. Background goroutine checking `GetTotalCacheSize()`
2. When over limit, get LRU artifacts
3. Delete from storage and clear database records
An object a record stops pointing at, because a refetch replaced it or its entry was discarded, is not deleted at once: a request that read the record may still be opening it. Its path goes into `pending_deletes`, and a background loop deletes it after a grace period of at least an hour, or `direct_serve_ttl` if longer, so signed URLs to it stay valid. This runs whether or not `max_size` is set.
2026-01-20 21:52:44 +00:00
## Design Decisions
**Why SQLite?**
- Simple deployment (single file)
- No external dependencies
- Good performance for this workload
- Pure Go driver available (no CGO)
**Why rewrite metadata URLs?**
- Ensures clients fetch artifacts through proxy
- Alternative: Let clients fetch directly, miss cache opportunity
**Why not cache metadata (by default)?**
2026-01-20 21:52:44 +00:00
- Simplicity - no invalidation logic needed
- Fresh data - new versions visible immediately
- Metadata is small, upstream fetch is fast
- Set `cache_metadata: true` or use the mirror command to enable metadata caching for offline use via the `metadata_cache` table
- OCI manifests and tag lists are exceptions: they are cached automatically so previously fetched images remain pullable and tag resolution works when the registry or token service is unavailable
2026-01-20 21:52:44 +00:00
**Why stream artifacts?**
- Memory efficient - don't load large files into RAM
- Better latency - start sending while still receiving
**Why atomic writes?**
- Prevents serving partial files
- Safe concurrent access
- Clean recovery from crashes