pkgproxy design: snap
Caching the Snap Store’s packages and assertions through the snap relay. Part of the pkgproxy design, whose index says which file holds each § number.
11. Snap package and assertion caching
The snap side lives in internal/snap (protocol facts) and
internal/snapproxy (the handler); gitlab.com/zygoon/pkgproxy#4.
11.1 Why this needs a different shape than the apt proxy
§1’s non-goals rule out caching, verifying, or
terminating TLS for HTTPS content, and
§3 explains
why: apt’s forward-proxy shape only works because plain http:// sources
exist to actually cache, with CONNECT as a blind tunnel for the https://
remainder. The Snap Store (api.snapcraft.io and its download CDN) has no
plain-HTTP fallback anywhere in the protocol — every request, including
bulk package downloads, is HTTPS-only. A blind tunnel therefore can’t cache
any of it, and TLS-terminating MITM is ruled out both on principle (it would
reverse §1’s non-goal) and in practice: snapd is
known to detect and reject TLS interception on its store connection.
Decision: pkgproxy exposes its own explicit HTTP endpoint
(internal/snapproxy.Handler) implementing a relaying/caching subset of
the Snap Store API v2, and snapd is pointed directly at it — not
tunneled — via the SNAPPY_FORCE_API_URL environment variable override (a
mechanism snapd’s own spread test suite uses to redirect all store
traffic to an arbitrary URL). This makes the snap side a genuine
reverse relay (pkgproxy is the origin snapd talks to) built on
net/http/httputil.ReverseProxy, structurally different from
internal/proxy’s forward-proxy Handler — not a variant of it. It was
checked against a real snapd that refresh, install, find and the
assertion fallback fetches all work through a plain-http:// relay.
The two still share one listener: internal/gateway tells them apart by
request-target shape, which never overlaps. apt’s traffic is CONNECT or
an absolute URI (r.URL.Host set); snapd’s is origin-form under /v2,
/api/v1 and /v1. So there is one -addr and no snap enable flag.
Official Snap Store Proxy registration (a Canonical-issued store id and
signed store assertion behind proxy.store=<id>) is declined: it needs a
Canonical SSO account and online registration, more than a self-hosted
cache should require.
-snap-external-url is empty by default, which makes the rewrite base the
request’s Host, the address SNAPPY_FORCE_API_URL gave snapd.
-snap-revalidate-ttl is separate from apt’s -revalidate-ttl: the two
sides’ freshness needs are not coupled.
11.2 Snap Store API v2 surface pkgproxy acts on
Base host api.snapcraft.io, HTTPS only, anonymous/public-snap access
only (a logged-in user’s answers are cached only when they show public snaps
alone, and a brand store’s device session’s never — see §11.6).
GET /v2/snaps/find: cached for theCache-Control: max-agethe store announces (five minutes, observed), by a key made of the search with its spelling normalised (the term lowercased with its whitespace collapsed, ASCII only, thefieldsas a set: the store answers “HELLO” and “hello”, “text editor” and “text editor”, byte for byte alike) and of the headers that select a response, asinfois. What was searched before is therefore found again offline. A search of private snaps (private=anything but a false) is relayed, and so is a brand store’s device session; a logged-in user’s search is cached only when every result is public (§11.6). The answer holds no download URL, so nothing in it needs rewriting.GET /v2/snaps/info/<name>: relayed and rewritten, cached withRevalidateTTL: no per-client state, the same reasoning as apt’sdists/**. A logged-in user’s is cached only when its snap is public (§11.6).POST /v2/snaps/refresh: relayed and rewritten, not cached as a whole: it carries a per-clientcontext[](installed revisions, tracking channel), so a byte-identical repeat across machines is unlikely. It is the endpointsnap refreshandsnap installuse, and the one that returns download URLs and, forfetch-assertionsactions,assertion-stream-urls. Its results are recorded per action (§11.7).GET /v2/assertions/<type>/<key...>: cached persnap.MutabilityFor(§11.4).GETof an embeddeddownload.url(any CDN path): rewritten to/v2/download/sha3-384/<hex>, this handler’s own content-addressed blob endpoint (§11.3, §11.4).GET /v2/snaps/categories,/v2/snaps/category/<name>,/v2/repairs/...: cached withRevalidateTTL, keyed on the whole request target and the headers that select a response (Snap-Device-*,Snap-Classic,X-Ubuntu-*).GET /api/v1/snaps/{sections,names,search},/api/v1/snaps,/api/v1/snaps/details/<name>: the legacy v1 search API, cached like the above. HAL+JSON packages with adownload_sha3_384get theirdownload_urlandanon_download_urlrewritten to the blob endpoint (the blob fetch follows the store’s redirect to the CDN), and their_linkshrefs re-based; icons and screenshots stay on the CDN.GET /api/v1/snaps/download/...,/api/v1/snaps/dm-verity/download/...,/api/v1/snaps/resources/...,/api/v1/resources/download/...,/api/v1/media/download/...: the store answers with a redirect to its CDN. The redirect is followed here and the body streamed to the client (Range and validators forwarded, credentials dropped on a hop to another host), uncached: there is no hash to key it by./api/v1/snaps/auth/*,/api/v1/snaps/metadata,/v1/*(device sessions, serials),POST /v2/cohorts, any method: relayed uncached, headers and body untouched: a nonce is single-use, and the rest is per device.
Cached entries live for the Cache-Control: max-age the store announced
for them (a day for the names catalogue, an hour for sections and
categories, observed), else for RevalidateTTL. The first max-age
directive decides (RFC 9111 §4.2.1), and one that is zero, negative or not
a number counts as none: RevalidateTTL is a floor, kept on purpose, on
how often the rate-limiting store is asked. The store’s load balancer
rate-limits by request rate (a bare 429, no Retry-After), and all
clients of a proxy share one address, so when the store cannot answer a
revalidation (a transport error, 429 or 5xx) the expired entry is
served instead, with a warning; with nothing to serve, the failure is
relayed as it was, and never cached.
Concurrent misses for a key share one fetch (internal/flight). It is a
GET whatever method the requests had (a HEAD is answered from the same
entry), without any client’s If-* validators or Range, since the answer
is not one client’s alone. It runs detached from the request that started
it, with a timeout of its own, and each request waits only as long as its
client does. An oversized body is an error, never a truncated entry.
Failures are not remembered: backing off is the circuit breaker’s job
(gitlab.com/zygoon/pkgproxy#57).
Everything else under /v2/ (unrecognized snap store paths) is still
relayed and rewritten, uncached — the snap-side analog of
PolicyPassthroughOnly
(§4/§5’s
bottom row), so an endpoint this package doesn’t specifically know about
still works rather than 404ing.
11.3 Response rewriting
Unlike the apt side, which never touches response bytes
(§1: “our only obligation is to serve those files
byte-identical to the origin”), internal/snapproxy is a real origin here
and must rewrite embedded URLs before they reach the client, or every
blob/assertion fetch would bypass the cache entirely by going straight to
the real CDN.
A relayed response is decoded as generic JSON and walked structurally,
rather than modeled as typed structs for the whole API surface: any
object with sibling "url"/"sha3-384" keys is a download object, and its
"url" becomes /v2/download/sha3-384/<hex>. A delta (an entry of
"deltas") has the same shape, so it is rewritten and cached like a full
snap: the store’s deltas are relayed, never generated here. Any
"assertion-stream-urls" entry is re-based to this handler’s host, its
path and query already mirroring /v2/assertions/.... Everything else
survives untouched, so unknown fields and API drift don’t break
rewriting. A body that fails to parse is relayed unmodified (logged, not
failed): one endpoint’s URLs may then bypass the cache, but no request
breaks.
The hash → real CDN URL mapping is required, not optional: the real
URL is opaque (sometimes tokenized) and cannot be derived from the hash.
It is recorded durably, with the blob’s size, through the same
store.Store every other object uses (a pending/sha3-384/<hex> record),
so a client holding a rewritten URL across a pkgproxy restart still
resolves. The record is left in place once the blob is cached, in keeping
with caching indefinitely (§1).
Compression. The store’s API documentation says nothing about
Accept-Encoding, but the store has been seen to send gzip-compressed
JSON, which cannot be rewritten as it is. The client’s Accept-Encoding
is therefore not passed upstream, and a body that is still encoded (gzip,
deflate) is decoded before it is rewritten and cached, so the cache only
holds decoded bodies and the client gets no Content-Encoding. An
encoding the standard library cannot undo (br, zstd) is relayed as it
is, with a warning, and not cached.
11.4 Cache keys and assertion mutability
A second, separate fslocal.Store instance, rooted at
<cache-dir>/snap (the apt side is likewise rooted at <cache-dir>/apt)
— not a shared keyspace with the apt side’s hostname-derived keys,
simpler than proving the two can never collide. Each side gets its own
top-level directory.
- Blobs: key
sha3-384/<hex>, alwaysImmutable: true. Real content-addressed storage, unlike apt’s path-normalization-plus- verification compromise (§4.2) — the hash is known before the fetch, from whichever relayed response referenced it. - Assertions: key
assertions/<type>/<path-suffix>(the request path suffix after/v2/assertions/, used as an opaque key — this package doesn’t need to understand each type’s primary-key field layout), with?sequence=N/?max-format=folded in when present.internal/snap.MutabilityFor(assertionType, hasSequence)classifies the type. Any assertion can be re-issued under its primary key with a higherrevision(a header snapd leaves out at 0), and snapd keeps the newest.snap-revision, keyed by the hash of the blob it vouches for, is cached as permanent: no snap-revision is known to have been re-issued (all 42 on one machine were at revision 0).account-keyis keyed by the hash of its public key, but is re-issued: the root key and the store’s own signing key are at revision 2, as a new revision can extend a key’suntilor change its constraints, so it revalidates with TTL, sequence or not.account,snap-declaration,storeandvalidation-setare “head”/sequence-forming types that can be updated or resequenced, so an unpinned fetch revalidates with TTL, and one where?sequence=Npins a specific point is cached as permanent. An unrecognized type defaults to revalidate, the same “never treated as forever-cacheable” conservative default §5’s bottom row uses for an unrecognized apt path shape. Whether a pinned sequence point, or a snap-revision, is ever re-issued is not confirmed against the store. - Info: key
info/<name>plus the query string and the request headers that select a different response (series and store when not the default, architecture, classic, capabilities, delta format;x--named so they cannot collide with a query parameter). That suffix is hashed: a realsnap infosends afields=list long enough to exceed a filename’s 255-byte limit.
A relayed JSON/assertion body has no pre-known hash to verify against before
being cached — the same acknowledged gap apt has for its own trust root,
InRelease
(§4.2).
.snap blobs, by contrast, are hash-verified (sha3-384) before they are
committed, but not served only after it: store-then-serve sends the client
nothing, not even headers, until the whole blob is fetched and hashed, and
snapd’s HTTP client gives up on a large snap before that. A miss is
therefore a fill
(§8.1,
internal/fill): the origin fetch runs detached from every client, writing
to the Store and hashing, and every client, including Range resumes,
follows the bytes as they are written. As the pending record carries the
blob’s size (§11.3), the headers, with a correct Content-Length, go out at
once. Content that fails the sha3-384 check is not cached, but the clients
already following the fill still get the origin’s bytes unmodified (the same
rule as apt’s §8 step 7): snapd
verifies sha3-384 itself and rejects a bad blob, the same “verification is
ultimately the client’s job too” principle §1 states
for apt’s InRelease. A HEAD on a miss is answered from the pending
record without fetching anything.
11.6 Explicit v1 scope boundaries
Requests whose answer may depend on who asks are a user’s (Authorization,
a macaroon, which can show the user’s private snaps) and a device session
(Snap-Device-Authorization) on a brand store, which Snap-Device-Store
names and which may restrict what it shows. A device session on the public
store is not one of them: snapd sends it on every request but the
catalogue’s once the device has registered a serial, which every Ubuntu
Core and brand device has, and it says which device asks, not what it may
see. Treating it as authentication would keep the cache, and offline mode,
from ever working for such a device.
A brand store’s device session is relayed, its URLs rewritten, and nothing it is answered is served from or saved to the cache, nor recorded.
A user’s request is served from the cache as any other, since only answers
anyone may see are kept. What the store answers it is kept only when the
store says every snap in it is public: snapd asks for private in every
info, find and action, and the answer about a public snap does not
depend on who asks. An info is cached when its snap says "private": false, a find when every result’s does (one with no results shows
nothing), and of an install, download or refresh the result of each such
snap is recorded (§11.7). An answer that shows a private snap, or does not
say, goes to that user alone. Without this, a machine logged in to the
store, as most desktops are, could find and install nothing offline. A
user’s fetch is shared only with that user’s identical requests in flight
(the macaroon is part of the fetch’s key, not of the cache key), so no other
client waits for, or is handed, an answer that may show the user’s private
snaps. snapd sends the user’s macaroon when it fetches assertions too, and
an assertion does not say whether its snap is private. The store does give a
private snap’s assertions to anyone who names them (observed: an anonymous
request for the snap-declaration of a private snap answers 200, with the
snap’s name), but a cache should not depend on that. So a user’s assertion
that is not cached is asked for without the macaroon, as any client’s, and
what the store answers that way, which anyone gets, is cached; when it does
not answer so, the user’s request is relayed with the macaroon and nothing
is kept. The catalogue is relayed for a user, uncached. In every case the
blobs named are cached by hash alone, as the store hands them out by signed
URL.
Out of scope: find’s full field/query surface (the query is cached as
asked, under a key that only normalises spelling, §11.2’s table; it is not
modeled or validated), GC/eviction (a project-wide non-goal,
§1), and official Snap Store Proxy registration
(§11.1).
11.7 Recording and synthesising refresh results
POST /v2/snaps/refresh is per client, so it cannot be cached as a whole,
yet without it snap install fails while pkgproxy does not contact the
store (offline mode, or the circuit breaker of store-overloaded open:
§21), even for a snap whose blob is cached.
What the store answers one action is not per client, though: installing a
snap from a channel, or refreshing it from a revision, has the same answer
for every client of the same series, architecture, store and fields. So the
handler (internal/snapproxy refresh.go, synth.go):
- records, when online and the answer is a 200 to a request that is not
a device session on a brand store (§11.6), the store’s own result for each
install, download or refresh action (for a logged-in user’s request, only
a result that says
"private": false), before its URLs are rewritten, with theSnap-Store-Version, the client’s epoch and the assertion formats asked for. The key is a hash of (series, architecture, store, classic,Snap-Device-Capabilitiesandfields) and of (name, channel as sent, revision) for an install or download, or (snap id, channel, revision) for a refresh, which keeps the newest revision only. The channel is as sent, but for the empty one, which isstable:stableis notlatest/stable, as snapd says it supports default tracks and the store reads a channel without a track as one of the snap’s default track. An action with a cohort,ignore-validationor validation sets (its own, or in the context of a refresh) is neither recorded nor answered: they change which revision the store picks; - synthesises the response when it is not contacting the store (and, in
store-overloadedmode with the breaker still closed, when the store failed and every action can be answered from a record, as otherwise the store’s own failure is relayed and snapd retries it): one result per action, with the client’s owninstance-key, the recorded result with its URLs rewritten for the way the client reached this proxy and its deltas dropped (snapd tries a delta first, and retries it for a minute and a half before it falls back to the full blob), andSnap-Store-VersionandX-Pkgproxy-Synthesisedheaders. An action that cannot be answered gets an error result of its own, with a code snapd does not know, so that it shows the message as it is (pkgproxy is not contacting the store and has no recorded answer for hello), and does not fail the other actions.
A refresh of a snap that was only ever installed through the proxy has no
refresh record, as no newer revision was ever offered online. Every install
is therefore also recorded under (snap id, channel, revision), and a refresh
falls back to it, using whichever of the two records has the newer revision.
The result is made to name the action it answers (refresh, not
install). Recording a result is a check of the old record’s revision and
a write, done under a per-key lock so that two clients recording at once
cannot leave the older revision.
snapd reads a refresh result with the revision it already has as “no
updates” (currentSnapMatchesStoreSnap), so that is what a client at the
recorded revision is told. A newer recorded revision is offered only to a
client with the same epoch as the one that was refreshed (the store hops
epochs, a recording does not know how); an older one is never offered, as
it would be a downgrade. A recorded revision is only offered when its blob
is cached, so the installable set is the snaps whose answer was recorded
and whose blob is cached. fetch-assertions actions are answered with no
stream URLs: the assertions an install needs are fetched one by one from
the cache.
An install must not fail halfway, so a revision is only offered when the cache
also holds everything snapd fetches to accept the blob (snap.CheckChain,
following asserts.Fetcher): the snap-revision for the blob’s digest (primary
key: its sha3-384 in unpadded base64url), which must name this snap id and
revision, the snap-declaration (16/<snap id>), the accounts of the developer
and the publisher, and the account-key that signed each assertion with the
account of that key, down to what snapd has built in (the canonical account
and its root key). snapd asks for each with max-format=<N>, the N it
supports for that type (asserts.MaxSupportedFormat: 7 for snap-declaration,
1 for account-key, 0 for snap-revision and account), so they are looked for
under the keys serveAssertion cached them by. A refresh request says those
formats in assertion-max-formats only when it also has assertions to resolve,
and names only the types above 0; a type the map does not name, or every type
when there is no map, is looked for under any N.
snapd asks only for the assertions it does not have, so a snap it installed
before it used the proxy, or reinstalled with the assertions it kept, would
be recorded with nothing to vouch for it. So once a refresh is answered
online (the response flushed, so snapd does not wait), the handler walks the
chain of each result it recorded and fetches from the store, anonymously,
each assertion the cache lacks, with the max-format snapd would ask for it
with: the one the request’s assertion-max-formats gives (0 for a type it
does not name), else the one a client last asked for the type with, else
snapd’s own (the values above). Each fetch is the one serveAssertion
makes, shared with a client’s request for the same assertion in flight. The
blob itself is not fetched ahead: that costs a snap’s size for each one
snapd did not download (gitlab.com/zygoon/pkgproxy#81).