Cache snap packages and assertions (snap-store reverse-relay proxy)

Goal

Cache .snap package downloads and snap assertions for a fleet of snapd clients, the snap-store analog of what pkgproxy already does for apt archives.

Why this needs a different shape than the apt proxy

The apt side is a forward proxy: apt keeps real mirror hostnames and is pointed at pkgproxy via Acquire::http::Proxy, with CONNECT blindly tunneled (uncached) for any https:// source — HTTPS content caching/MITM is an explicit non-goal (docs/design.md §1).

The snap store (api.snapcraft.io + its download CDN) is HTTPS-only with no plain-HTTP fallback anywhere in the protocol. A blind CONNECT tunnel can't cache it, and TLS-terminating MITM was ruled out both on principle (reverses the documented 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 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 override. This makes pkgproxy a genuine reverse-proxy/relay for snap traffic (internal/snapproxy, built on httputil.ReverseProxy), a different serving model from the apt forward proxy (internal/proxy), not a variant of it.

Live-verified 2026-08-19 against this machine's real snapd (2.77, series 16): SNAPPY_FORCE_API_URL does redirect all real snapd store traffic — GET /v2/snaps/find, POST /v2/snaps/refresh, and GET /v2/assertions/<type>/... fallback fetches — to an arbitrary plain http:// URL, and a transparent relay to the real store round-trips snap find and sudo snap refresh successfully end-to-end. This de-risks the core mechanism the whole design depends on.

Design summary

  • Cache: .snap blobs (content-addressed by sha3-384, cache forever — the hash is known before the fetch, real CAS unlike apt's path-normalization compromise) and assertions (account-key/ snap-revision immutable; account/snap-declaration/store/ validation-set revalidate with TTL, or immutable when pinned via ?sequence=N — verify the mutability classification against live behavior during implementation, it's an assumption not a confirmed fact). GET /v2/snaps/info/<name> gets a short-TTL revalidate cache (no per-client context, same reasoning as apt's dists/**).
  • Relay only, no caching: POST /v2/snaps/refresh (per-client context[], not cacheable across machines) and GET /v2/snaps/find (broad query surface, low repeat value).
  • Response rewriting: since pkgproxy is a real origin here (not transparent), every relayed JSON response has its embedded download.url fields rewritten to /v2/download/sha3-384/<hex> so the client's blob fetch lands back on pkgproxy, and assertion-stream-urls re-based to pkgproxy's own host. deltas[] is stripped so every download is a full-file fetch pkgproxy can see and cache. Decode as map[string]any and recurse structurally (not typed structs) so unknown fields and minor API drift survive untouched.
  • Persisted hash→origin-URL map: the rewritten download URL is opaque (arbitrary CDN path, not derivable from the hash), so pkgproxy must remember what it originally pointed at. Persisted through the existing store.Store interface as a small pending/sha3-384/<hex> record (no new persistence layer), not in-memory-only, so a client holding a previously rewritten URL across a pkgproxy restart still resolves.
  • New packages: internal/snap (protocol constants + assertion mutability table, no typed request/response structs) and internal/snapproxy (the handler). Reuses internal/store/fslocal unchanged (new instance rooted at <cache-dir>/snap). New CLI flags: -snap-addr (default empty/disabled until this lands), -snap-store-url (default https://api.snapcraft.io), -snap-external-url, -snap-revalidate-ttl.

Explicit v1 scope boundaries

Private/authenticated snaps, find's full field surface, delta downloads, the legacy /api/v1/snaps/assertions/... path, GC/eviction (already a project-wide deferred non-goal), official Snap Store Proxy registration (Canonical store-id + signed store assertion), and multi-origin/mirror-alias handling (there's exactly one real store URL) are all out of scope.

Verification plan

  1. Unit tests for the rewrite walker, assertion mutability table, and hash-verify-then-commit path (no network).
  2. httptest-based handler tests analogous to internal/proxy/proxy_test.go.
  3. Exploratory: point a real snapd at a running pkgproxy snap listener via SNAPPY_FORCE_API_URL, run snap find/install/refresh, confirm cache hits on repeat runs and successful assertion/trust verification. Real end-to-end snapd testing likely needs a VM-based harness (systemd, squashfs, seccomp) rather than a bare podman container, unlike the apt integration test.

Full design notes will land in docs/design.md as part of the same change that implements this, per AGENTS.md.