Files

251 lines
10 KiB
Markdown

# 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](#quick-start)
- [Why trust a box some rando plugged into the LAN?](#why-trust-a-box-some-rando-plugged-into-the-lan)
- [Discovery](#discovery-per-deployment-policy)
- [Central registry API](#central-registry-api-implemented-on-oxmcme-not-in-this-repo)
- [Device-facing asset protocol](#device-facing-asset-protocol)
- [Package layout](#package-layout)
---
## Quick start
```bash
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/`](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**<br>*(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**<br>*(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`](../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`](../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.
```http
POST https://oxmc.me/apis/aosp/cache/register
```
```jsonc
// 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>"
}
```
```jsonc
// 200 response
{ "serverId": "<uuid>", "token": "<base64 signed CacheToken>", "expiresAt": "<iso8601>" }
```
```http
GET https://oxmc.me/apis/aosp/cache/lookup
```
*(no body — server reads the caller's own public IP from the connection)*
```jsonc
// 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)
```json
{
"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_PUBKEY` — **placeholder value, must be
replaced with the real deployment key before shipping**).
> For local development, see [`dev-registry/`](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
```