pawletcache-server
Local network content cache for PawletOS device updates — the same idea as Apple's Content Caching service, scoped to what
BgUpdfetches (component APKs/manifests: system apps, custom apps, webview providers).
Runs on a box on your LAN (NAS, Raspberry Pi, home server) — not on the
device. Devices discover it, verify it's a legitimately registered cache
(not a rogue LAN box), and route BgUpd asset downloads through it instead
of oxmc.me directly.
OTA system images and general media caching are explicitly out of scope for
this daemon; they'd be a separate cache class behind the same client-side
resolver (PawletCacheService on-device), not this binary.
Contents
- Quick start
- Why trust a box some rando plugged into the LAN?
- Discovery
- Central registry API
- Device-facing asset protocol
- Package layout
Quick start
git clone https://git.oxmc.me/PawletOS/cache-server.git pawletcache-server
cd pawletcache-server
npm install
sudo mkdir -p /etc/pawletcache
sudo cp config.example.yml /etc/pawletcache/config.yml
# edit enrollmentToken, hostname
sudo node src/index.js
Requires Node ≥ 20 (uses global fetch and Readable.fromWeb). No build
step — CommonJS throughout.
Testing locally without a real oxmc.me account? See
dev-registry/ and scripts/smoke-test.sh — a throwaway
stand-in registry plus an end-to-end script that exercises register →
token → cache miss/hit → SSRF rejection.
Why trust a box some rando plugged into the LAN?
Two independent layers, deliberately overlapping:
| Layer | What it stops | How |
|---|---|---|
| Content integrity (already exists, unrelated to this project) |
A malicious cache serving tampered/wrong bytes | BgUpd's SignatureVerifier checks the downloaded APK's signing certificate against the SHA-256 the manifest declared, regardless of which host served it. A malicious cache can serve garbage or nothing; it cannot get bad code installed. |
| Server identity (what this project adds) |
A malicious cache impersonating a legitimate one — DoS'ing updates, or passively fingerprinting which components/versions a device runs | Signed registration + TLS pinned to the attested fingerprint (below). |
Signed registration. The server generates an Ed25519 keypair on first
run, registers with a central authority (oxmc.me), and gets back a short
signed cache token binding its identity to a TLS certificate fingerprint.
Devices verify that signature against a public key baked into the OS — no
network round-trip required, so this also works for LAN-only/offline
enterprise deployments.
TLS pinned to that fingerprint. A device doesn't trust whatever's speaking the cache protocol on port 8443 — it pins the connection to the exact key fingerprint the signed token attests to.
central authority private key (never leaves oxmc.me)
│ signs
▼
cache token (pubkey + TLS SPKI fingerprint + expiry)
│ verified by device against
▼
pinned public key baked into PawletCacheService
Discovery (per deployment policy)
Both discovery paths run by default; either can be pinned or disabled via policy (see the table below).
| Method | Trigger | Reach | Notes |
|---|---|---|---|
| mDNS/DNS-SD | Always tried on LAN (fallback) | Same broadcast domain only | Server advertises _pawletcache._tcp.local. with the signed cache token in a TXT record. Zero WAN dependency. |
| Central lookup | Preferred by default | Any VLAN sharing one WAN egress | Server registers its public IP with oxmc.me. Device asks "is there a registered cache behind my public IP?" — the lookup uses the request's own source IP, the same trick Apple's content caching uses (nothing client-supplied to spoof). Needs oxmc.me reachable. |
Policy is resolved from three tiers, most specific wins:
- Runtime override —
/data/misc/pawletcache/policy.json. Written by acontent-cachepayload frompawletprofiled(git.oxmc.me/PawletOS/profiled) via MDM push, any time after first boot. Can pinmode: lan/central/disabled, or an exact server + fingerprint, skipping discovery entirely. Seepawletprofiled-config-schema/schema/profile.schema.yml. - Vendor/OEM default —
/vendor/etc/pawletcache/policy.json. Same file shape, read-only, set at build time. Lets a device builder ship a standing default with no MDM enrollment needed. Seeandroid_packages_apps_PawletCache/vendor-config/README.md. - Compiled-in fallback — central lookup preferred, mDNS as fallback if central is unreachable or returns nothing.
See PolicyOverride.kt for the exact tiering logic.
Central registry API (implemented on oxmc.me, not in this repo)
This daemon is a client of two endpoints. They're out-of-tree (server infra), documented here so both sides agree on the contract.
POST https://oxmc.me/apis/aosp/cache/register
// Request body
{
"hostname": "cache.local.lan",
"port": 8443,
"pubkeyEd25519": "<base64 SPKI>",
"tlsSpkiSha256": "<base64>",
"signedAt": "<iso8601>",
"signature": "<base64 Ed25519 sig over the 5 fields above, canonicalized as JSON with sorted keys>",
"enrollmentToken": "<admin-issued>"
}
// 200 response
{ "serverId": "<uuid>", "token": "<base64 signed CacheToken>", "expiresAt": "<iso8601>" }
GET https://oxmc.me/apis/aosp/cache/lookup
(no body — server reads the caller's own public IP from the connection)
// 200 response
{ "available": true, "token": "<base64 signed CacheToken>" }
// or
{ "available": false }
enrollmentToken is how you keep randoms from registering a cache server
against your oxmc.me account — issue one per deployment out of band. The
signature proves the same server is renewing (its persisted Ed25519 key
signs every registration/renewal; the central registry pins pubkeyEd25519
to serverId on first registration and expects renewals signed by it).
CacheToken — the signed payload (JSON before base64+signing)
{
"serverId": "uuid",
"pubkeyEd25519": "base64",
"tlsSpkiSha256": "base64",
"hostname": "cache.local.lan",
"lanHost": "192.168.1.50",
"port": "8443",
"issuedAt": "2026-07-24T00:00:00Z",
"expiresAt": "2026-08-23T00:00:00Z"
}
- Every field is a string,
portincluded — kept a string so device-side canonicalization can treat every payload field uniformly (seeCacheTokenVerifier.kt). lanHost/portare what the device actually connects to. Deliberately inside the signed payload rather than sitting next totokenin the lookup response, so nothing on the path between device and registry can redirect a device to a different host without invalidating the signature.hostnamestays separate — it's the server's self-reported identity (matches what it advertises via mDNS).lanHostis what the central registry resolved for this device's lookup (may differ once you're doing anything more than single-subnet matching).
Signed with the central authority's Ed25519 private key (held only by
oxmc.me). The corresponding public key is compiled into PawletCacheService
(see Constants.CACHE_TRUST_ROOT_PUBKEY — placeholder value, must be
replaced with the real deployment key before shipping).
For local development, see
dev-registry/— a throwaway stand-in registry you run yourself, generating its own root keypair, so you can test the whole register → discover → verify → cache flow without touching realoxmc.meinfra or its signing key.
Wire format
Every token string (register response, lookup response, mDNS TXT record)
is the same envelope:
base64( JSON.stringify({ payload: <CacheToken fields>, signature: base64(...) }) )
signature is the central authority's Ed25519 signature over the canonical
JSON of payload alone (JSON.stringify with keys sorted, matching
registration.js's canonicalize()).
Device-side verification: base64-decode → JSON-parse → re-canonicalize
payload → verify signature against the pinned root public key → check
expiresAt.
Device-facing asset protocol
Once a device trusts a cache server (mDNS or central lookup + signature
verified), BgUpd rewrites its download from oxmc.me to the cache:
GET https://<cache-host>:<port>/asset?url=<url-encoded original download_url>
On a miss, the daemon downloads the full origin URL to disk first, then
serves it — to the request that triggered the miss, and every request after
— from disk. Concurrent misses for the same URL collapse into one origin
fetch. No parsing of BgUpd's manifest format happens here; it's a dumb
reverse-proxy cache keyed by URL, which is why OTA/media can reuse the same
server later just by pointing their own resolvers at it.
SSRF guard:
urlis restricted toconfig.yml'sallowedOrigins. Without that check this would be an open proxy pivot for anything on the LAN that can reach port 8443.
Package layout
pawletcache-server/
├── package.json
├── config.example.yml
├── dev-registry/ Local stand-in for the oxmc.me registry — see its own README
├── scripts/
│ ├── dummy-origin.js Throwaway HTTPS origin for the smoke test
│ └── smoke-test.sh End-to-end register → cache → asset-fetch check
└── src/
├── index.js Entry point, wires every subsystem together
├── config.js config.yml loading (js-yaml)
├── identity.js Ed25519 keypair + self-signed TLS keypair persistence
├── registration.js POST /register against oxmc.me, token refresh loop
├── mdns.js _pawletcache._tcp advertiser (bonjour-service)
├── cache.js Asset fetch-through cache (disk-backed)
└── server.js HTTPS listener serving /asset