pawletcache-server

Local network content cache for PawletOS device updates — the same idea as Apple's Content Caching service, scoped to what BgUpd fetches (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

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:

  1. Runtime override/data/misc/pawletcache/policy.json. Written by a content-cache payload from pawletprofiled (git.oxmc.me/PawletOS/profiled) via MDM push, any time after first boot. Can pin mode: lan / central / disabled, or an exact server + fingerprint, skipping discovery entirely. See pawletprofiled-config-schema/schema/profile.schema.yml.
  2. 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. See android_packages_apps_PawletCache/vendor-config/README.md.
  3. 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, port included — kept a string so device-side canonicalization can treat every payload field uniformly (see CacheTokenVerifier.kt).
  • lanHost/port are what the device actually connects to. Deliberately inside the signed payload rather than sitting next to token in the lookup response, so nothing on the path between device and registry can redirect a device to a different host without invalidating the signature.
  • hostname stays separate — it's the server's self-reported identity (matches what it advertises via mDNS). lanHost is 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_PUBKEYplaceholder 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 real oxmc.me infra 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: url is restricted to config.yml's allowedOrigins. 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
S
Description
No description provided
Readme
74 KiB
Languages
JavaScript 82.5%
Shell 17.5%