Add an opt-in open URL cache at /url/ - #399
benoit-nexthop wants to merge 1 commit into
Conversation
GET /url/{host}/{path}?{query} fetches https://{host}/{path}?{query} from
any host and caches it as an immutable artifact; /url/sha256/{hex}/... also
verifies the download against that digest, refetching a cached copy with
another one. Files stream into the artifact cache, so unlike /generic/'s
metadata path there is no size cap.
With url_proxy.direct_serve, GETs are redirected to the stored object, on
hits and right after a miss is stored: to storage.direct_serve_public_url
when the bucket serves objects anonymously, else to a presigned URL. HEAD
is answered from the cache record. Before redirecting, the object's
existence is checked; one removed behind the proxy's back (a bucket
lifecycle rule) is refetched once and counted in
proxy_cache_missing_objects_total.
The route fetches through its own client: the safehttp dial gate on every
redirect hop, no upstream.auth credentials or OCI token exchange, no
environment HTTP proxy, and url_proxy.fetch_timeout (default 9m). It is
off by default since it is an open proxy for its clients.
|
BTW we're finding this proxy super useful for our builds, I know this is a fairly recent project started earlier this year, so thanks for open sourcing it! |
|
Thanks for the thorough writeup; I want to cover this case, and the client isolation in Two pieces would be better as their own PRs:
For the route itself, I'd like it folded into upstream:
generic:
github: "https://github.com" # existing shorthand
sources:
hosts: ["*"] # or ["*.gnu.org", "github.com"]
immutable: true
direct_serve: true
fetch_timeout: "9m"
On that last point: how does the digest reach the proxy from your builds? Bazel's If folding into |
Why
Some build systems don't fetch from a package registry. They download pinned source archives straight from wherever each project publishes them: GitHub release assets and
/archive/tarballs, GNU and kernel.org mirrors, project download pages, and so on. One build can pull from a few dozen hosts, and the list changes whenever a dependency moves. Usually the build already knows each archive's sha256.We'd like to cache those downloads with the proxy we already run for PyPI, npm and crates.io.
/generic/isn't a good fit for this:metadata_max_size(100MB by default) and revalidates it aftermetadata_ttl. Archives that are both large and immutable are better served by the artifact cache.This PR adds an opt-in route for that case. We realise it is an open proxy for whoever can reach it, unlike
/generic/, whose docs promise it isn't one. So it is off by default, and the trade-offs are documented below and indocs/configuration.md. We run it in a staging and a production deployment on an internal network.What it adds
GET|HEAD /url/{host}/{path}?{query}, switched on withurl_proxy.enabled.https://{host}/{path}?{query}. Only https on port 443 is fetched; ports, userinfo and IPv6 literals are rejected.GetOrFetchArtifactFromURL[WithDigest], so it gets the existing coalescing, per-fetch storage paths, eviction and the denylist, with no size limit./url/sha256/{hex}/{host}/{path}also checks the download against that digest.Cache identity:
urlStorage paths therefore look like
url/{host}/{hash}/{fetch id}/{file}.Redirects (
url_proxy.direct_serve), for this route only. GET requests get a 302 to the stored object, both on a hit and right after a miss is stored.storage.direct_serve.storage.direct_serve_public_url, the target is a plain URL under a bucket location that serves objects anonymously, e.g.http://rgw:7480/bucket/prefix, so it never expires. Globalstorage.direct_serveuses this too when it's set, through a smalldirectServeURLhelper shared withcheckCache.Objects that disappear. Before redirecting, the handler checks the object still exists. A record whose object was removed outside the proxy, for example by a bucket lifecycle rule, is refetched once, and a second failure returns 502. Each case is counted in the new
proxy_cache_missing_objects_total{ecosystem}. The streaming path already recovered like this through the shared fetch's recheck.Config:
url_proxy.enabled,url_proxy.direct_serve,url_proxy.fetch_timeout(default9m, a whole-body bound), andstorage.direct_serve_public_url, each with aPROXY_*environment variable.url_proxy.enabledis rejected together withstorage.cache_artifacts: false, likedirect_serveandmirror_api. The route is labelledurlin request metrics and in the dashboard's ecosystem list.Security considerations
The route will fetch and store any public https file for any client that can reach it. Here's what limits that, and what's left to operators.
Built in:
mirror_api.newURLProxyinserver.go):169.254.169.254) are refused on every redirect hop.upstream.allow_private_hostsandupstream.allow_loopbackstill apply.upstream.authcredentials and the OCI bearer-challenge exchange never apply to arbitrary hosts.Transport.Proxy, so anHTTPS_PROXYin the environment can't be used to reach targets the dial gate would refuse.[A-Za-z0-9._+-].Left to operators, as documented:
storage.max_sizeto bound how much it can store.direct_serve_public_urlrequires the bucket, or at least itsurl/prefix, to allow anonymous reads. That suits caches of public downloads only.Not addressed: bandwidth and storage abuse by trusted-network clients beyond
max_sizeeviction, rate limiting, and a host allowlist or denylist. A per-route host allowlist would be easy to add if you'd prefer the route to support one.Testing
go test -race ./...andgolangci-lint run ./...(v2.13.1, as in CI) pass.internal/handler/urlproxy_test.go:internal/server/urlproxy_test.gochecks that the client refuses loopback targets and sends no configured credentials. There are also config andPublicObjectURLtests.direct_serve_public_url. Cold and cached downloads of a 124MB GitHub release asset, digest rejection, refusal of169.254.169.254, and recovery from a deleted object all behaved as described.Open questions
/url/,url_proxyand theurlecosystem are placeholders.storage.direct_serve_public_urlis useful without/url/. I'm happy to split it into its own PR.🤖 Generated with Claude Code