Initial commit: local network content cache for BgUpd component downloads
Node.js daemon: Ed25519 identity + self-signed TLS, signed registration/ renewal with the central registry, mDNS advertising, disk-backed fetch-through asset cache with an origin allowlist. Includes a local dev-registry stand-in for the real oxmc.me endpoints and an end-to-end smoke test (register -> token -> cache miss/hit -> SSRF rejection). See README.md for the full protocol and trust model.
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dev-registry/data/
|
||||
.smoke-test-tmp-*/
|
||||
@@ -0,0 +1,207 @@
|
||||
# pawletcache-server
|
||||
|
||||
Local network content cache for PawletOS device updates — same idea as Apple's
|
||||
Content Caching service, scoped for now to what `BgUpd` already fetches
|
||||
(component APKs/manifests: system apps, custom apps, webview providers).
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## Why trust a box some rando plugged into the LAN?
|
||||
|
||||
Two independent layers, deliberately overlapping:
|
||||
|
||||
1. **Content integrity** — already exists, unrelated to this project.
|
||||
`BgUpd`'s `SignatureVerifier` checks the downloaded APK's signing
|
||||
certificate against the SHA-256 the manifest declared, regardless of
|
||||
which host served the bytes. A malicious cache can serve garbage or
|
||||
nothing; it cannot get bad code installed, silently or otherwise.
|
||||
|
||||
2. **Server identity** — what this project adds. Without it, a malicious
|
||||
cache could still (a) impersonate a cache server to DoS updates for a
|
||||
whole LAN, or (b) passively learn exactly which components/versions a
|
||||
device is checking, a fingerprinting/recon signal you don't want handed
|
||||
to an unauthenticated box. So:
|
||||
- **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 to the pinned fingerprint** — the device don't just trust
|
||||
whatever's speaking the cache protocol on port 8443; it pins the
|
||||
connection to the exact key fingerprint the signed token attests to.
|
||||
|
||||
Trust chain: `central authority private key` (never leaves `oxmc.me`) signs
|
||||
→ `cache token` (server's pubkey + TLS SPKI fingerprint + expiry) → device
|
||||
verifies against the pinned public key baked into `PawletCacheService`.
|
||||
|
||||
---
|
||||
|
||||
## Discovery (both, per deployment policy)
|
||||
|
||||
- **mDNS/DNS-SD** (default fallback, always tried on LAN): server
|
||||
advertises `_pawletcache._tcp.local.` with the signed cache token in a
|
||||
TXT record. Zero WAN dependency, same-broadcast-domain only.
|
||||
- **Central lookup** (default preference): server also registers its
|
||||
*public* IP with `oxmc.me`. Device asks `oxmc.me` "is there a registered
|
||||
cache behind my public IP" (the lookup uses the request's own source IP,
|
||||
same trick Apple's content caching uses — no client-supplied IP to spoof).
|
||||
Works across VLANs sharing one WAN egress; needs `oxmc.me` reachable.
|
||||
- **Enterprise override** (`/data/misc/pawletcache/policy.json`, runtime) —
|
||||
a `vesperprofiled` `content-cache` payload can pin `mode: lan` /
|
||||
`mode: central` / `mode: disabled`, or pin an exact server + fingerprint,
|
||||
skipping discovery entirely. See
|
||||
`vesperprofiled-config-schema/schema/profile.schema.yml`.
|
||||
- **Vendor/OEM default** (`/vendor/etc/pawletcache/policy.json`, build-time)
|
||||
— same file shape, one tier below the runtime override. Lets a device
|
||||
builder ship a standing default (no MDM enrollment needed) — see
|
||||
`android_packages_apps_PawletCache/vendor-config/README.md`.
|
||||
|
||||
Device policy resolution, most specific wins: runtime override → vendor
|
||||
default → compiled-in fallback (central lookup preferred, mDNS as fallback
|
||||
if central is unreachable or returns nothing). See
|
||||
`PolicyOverride.kt` for the exact tiering.
|
||||
|
||||
---
|
||||
|
||||
## Central registry API (implemented on `oxmc.me`, not in this repo)
|
||||
|
||||
This daemon is a client of these 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
|
||||
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 { "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 { "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/was told to hand back 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 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 needed here — it's a dumb
|
||||
reverse-proxy cache keyed by URL, which is why OTA/media can reuse the same
|
||||
server later just by having their resolvers point at it too.
|
||||
|
||||
`url` is restricted to `config.yml`'s `allowedOrigins` — without that check
|
||||
this would be an open SSRF/proxy pivot for anything on the LAN that can
|
||||
reach port 8443.
|
||||
|
||||
---
|
||||
|
||||
## Package layout
|
||||
|
||||
```
|
||||
pawletcache-server/
|
||||
├── package.json
|
||||
├── config.example.yml
|
||||
└── 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
|
||||
```
|
||||
|
||||
CommonJS throughout (`require`/`module.exports`), Node >= 20 (uses global
|
||||
`fetch` and `Readable.fromWeb`).
|
||||
|
||||
## Building
|
||||
|
||||
```bash
|
||||
cd pawletcache-server
|
||||
npm install
|
||||
```
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /etc/pawletcache
|
||||
sudo cp config.example.yml /etc/pawletcache/config.yml
|
||||
# edit enrollmentToken, hostname
|
||||
sudo node src/index.js
|
||||
# or, after `npm link` / global install:
|
||||
sudo pawletcache-server
|
||||
```
|
||||
@@ -0,0 +1,14 @@
|
||||
# Copy to /etc/pawletcache/config.yml and fill in enrollmentToken + hostname.
|
||||
|
||||
hostname: cache.local.lan # must resolve/be reachable on the LAN
|
||||
listenAddr: "0.0.0.0"
|
||||
listenPort: 8443
|
||||
|
||||
storageDir: /var/lib/pawletcache/assets
|
||||
identityDir: /var/lib/pawletcache/identity
|
||||
|
||||
centralUrl: https://oxmc.me
|
||||
enrollmentToken: "REPLACE_ME" # issued out-of-band per deployment
|
||||
|
||||
allowedOrigins:
|
||||
- oxmc.me
|
||||
@@ -0,0 +1,59 @@
|
||||
# pawletcache-dev-registry
|
||||
|
||||
Throwaway local stand-in for the `oxmc.me` `/apis/aosp/cache/register` and
|
||||
`/apis/aosp/cache/lookup` endpoints documented in `../README.md`. Exists so
|
||||
`pawletcache-server` (and, separately, the Android `CentralLookupClient`) can
|
||||
be exercised end-to-end without touching real `oxmc.me` infra or its actual
|
||||
signing key.
|
||||
|
||||
**Not production code.** Two shortcuts that would be wrong on the real
|
||||
`oxmc.me`:
|
||||
- `lookup()` hands back whichever server registered most recently, full
|
||||
stop — no public-IP/NAT matching, because on localhost there's no NAT to
|
||||
match. The real registry has to match the *caller's* public IP against a
|
||||
registered server's public IP (see main README's Apple-content-caching
|
||||
comparison).
|
||||
- Zero persistence hardening — `data/registrations.json` is a flat JSON
|
||||
file, fine for a laptop, not for anything real.
|
||||
|
||||
Everything about the actual wire protocol (request/response shape, what's
|
||||
signed, the CacheToken format) is the real thing, though — this is a
|
||||
faithful implementation of the spec, just with a self-issued root key and a
|
||||
dumb `lookup()`.
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
cd dev-registry
|
||||
DEV_ENROLLMENT_TOKENS=dev-local-token node src/index.js
|
||||
```
|
||||
|
||||
First run generates a root Ed25519 keypair under `data/identity/` and prints
|
||||
its public half — paste that into
|
||||
`android_packages_apps_PawletCache/src/os/pawlet/cache/Constants.kt`'s
|
||||
`CACHE_TRUST_ROOT_PUBKEY_BASE64` if you want a real device/emulator to trust
|
||||
tokens this registry issues.
|
||||
|
||||
Env vars:
|
||||
| Var | Default | |
|
||||
|---|---|---|
|
||||
| `DEV_REGISTRY_PORT` | `4000` | |
|
||||
| `DEV_REGISTRY_DATA_DIR` | `./data` | |
|
||||
| `DEV_ENROLLMENT_TOKENS` | `dev-local-token` | comma-separated allowlist |
|
||||
| `DEV_TOKEN_TTL_MS` | `600000` (10 min) | short by default so you can actually see `pawletcache-server`'s renewal loop fire during a normal test session |
|
||||
|
||||
## Pointing pawletcache-server at it
|
||||
|
||||
In `../config.yml`:
|
||||
```yaml
|
||||
centralUrl: http://localhost:4000
|
||||
enrollmentToken: dev-local-token
|
||||
```
|
||||
(Plain `http://` — this dev registry doesn't bother with TLS; the real
|
||||
`oxmc.me` obviously would. `pawletcache-server`'s own asset-serving HTTPS
|
||||
endpoint is unaffected either way, that's a separate TLS cert.)
|
||||
|
||||
## See also
|
||||
|
||||
`../scripts/smoke-test.sh` — drives this registry + `pawletcache-server`
|
||||
together and checks the whole register → token → cache flow with curl.
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "pawletcache-dev-registry",
|
||||
"version": "1.0.0",
|
||||
"description": "Throwaway local stand-in for oxmc.me's cache registry API — for testing pawletcache-server end-to-end without touching real infra.",
|
||||
"license": "Apache-2.0",
|
||||
"type": "commonjs",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"start": "node src/index.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
const { existsSync, readFileSync, writeFileSync, mkdirSync } = require("node:fs");
|
||||
const { join, dirname } = require("node:path");
|
||||
|
||||
/**
|
||||
* Tiny JSON-file "database" — one row per hostname. Good enough for local
|
||||
* testing; a real oxmc.me implementation would use an actual database and
|
||||
* proper public-IP/NAT matching for lookup() instead of "most recent wins".
|
||||
*/
|
||||
class Db {
|
||||
constructor(path) {
|
||||
this.path = path;
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
this.rows = existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : {};
|
||||
}
|
||||
|
||||
get(hostname) {
|
||||
return this.rows[hostname] ?? null;
|
||||
}
|
||||
|
||||
put(hostname, row) {
|
||||
this.rows[hostname] = row;
|
||||
writeFileSync(this.path, JSON.stringify(this.rows, null, 2));
|
||||
}
|
||||
|
||||
/** Dev-only stand-in for "which server is on my public IP" — see README.md. */
|
||||
mostRecentlySeen() {
|
||||
const all = Object.values(this.rows);
|
||||
if (all.length === 0) return null;
|
||||
return all.reduce((a, b) => (a.lastSeenAt > b.lastSeenAt ? a : b));
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { Db };
|
||||
@@ -0,0 +1,157 @@
|
||||
#!/usr/bin/env node
|
||||
const { createServer } = require("node:http");
|
||||
const { randomUUID, verify: edVerify, createPublicKey } = require("node:crypto");
|
||||
const { join } = require("node:path");
|
||||
const { Db } = require("./db.js");
|
||||
const { loadOrCreateRootKey, signCacheToken, canonicalize, pemToBase64Spki } = require("./signing.js");
|
||||
|
||||
const PORT = Number(process.env.DEV_REGISTRY_PORT || 4000);
|
||||
const DATA_DIR = process.env.DEV_REGISTRY_DATA_DIR || join(__dirname, "..", "data");
|
||||
const ENROLLMENT_TOKENS = new Set(
|
||||
(process.env.DEV_ENROLLMENT_TOKENS || "dev-local-token").split(",").map((s) => s.trim()),
|
||||
);
|
||||
const TOKEN_TTL_MS = Number(process.env.DEV_TOKEN_TTL_MS || 10 * 60_000); // short by default, see README
|
||||
|
||||
const root = loadOrCreateRootKey(join(DATA_DIR, "identity"));
|
||||
const db = new Db(join(DATA_DIR, "registrations.json"));
|
||||
|
||||
function readJsonBody(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let body = "";
|
||||
req.on("data", (chunk) => (body += chunk));
|
||||
req.on("end", () => {
|
||||
try {
|
||||
resolve(body ? JSON.parse(body) : {});
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function sendJson(res, status, obj) {
|
||||
const body = JSON.stringify(obj);
|
||||
res.writeHead(status, { "content-type": "application/json", "content-length": Buffer.byteLength(body) });
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
function verifyRequestSignature(payload, pubkeyEd25519Base64, signatureBase64) {
|
||||
try {
|
||||
const publicKey = createPublicKey({
|
||||
key: Buffer.from(pubkeyEd25519Base64, "base64"),
|
||||
format: "der",
|
||||
type: "spki",
|
||||
});
|
||||
return edVerify(null, Buffer.from(canonicalize(payload)), publicKey, Buffer.from(signatureBase64, "base64"));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRegister(req, res) {
|
||||
let body;
|
||||
try {
|
||||
body = await readJsonBody(req);
|
||||
} catch {
|
||||
return sendJson(res, 400, { error: "malformed JSON body" });
|
||||
}
|
||||
|
||||
const { hostname, port, pubkeyEd25519, tlsSpkiSha256, signedAt, signature, enrollmentToken } = body;
|
||||
if (!hostname || !port || !pubkeyEd25519 || !tlsSpkiSha256 || !signedAt || !signature) {
|
||||
return sendJson(res, 400, { error: "missing required field" });
|
||||
}
|
||||
if (!ENROLLMENT_TOKENS.has(enrollmentToken)) {
|
||||
return sendJson(res, 403, { error: "invalid enrollmentToken" });
|
||||
}
|
||||
|
||||
const signedPayload = { hostname, port, pubkeyEd25519, tlsSpkiSha256, signedAt };
|
||||
if (!verifyRequestSignature(signedPayload, pubkeyEd25519, signature)) {
|
||||
return sendJson(res, 403, { error: "registration request signature invalid" });
|
||||
}
|
||||
|
||||
// Continuity check: if this hostname registered before, the same Ed25519
|
||||
// key must be renewing it — a valid enrollmentToken alone isn't enough to
|
||||
// take over an already-registered hostname with a different key.
|
||||
const existing = db.get(hostname);
|
||||
if (existing && existing.pubkeyEd25519 !== pubkeyEd25519) {
|
||||
return sendJson(res, 409, { error: `hostname ${hostname} already registered with a different key` });
|
||||
}
|
||||
|
||||
const serverId = existing?.serverId ?? randomUUID();
|
||||
const now = Date.now();
|
||||
const remoteAddress = req.socket.remoteAddress?.replace(/^::ffff:/, "") ?? "127.0.0.1";
|
||||
|
||||
const tokenPayload = {
|
||||
serverId,
|
||||
pubkeyEd25519,
|
||||
tlsSpkiSha256,
|
||||
hostname,
|
||||
// Dev shortcut: trust the caller's own claimed LAN address if it looks
|
||||
// like one, else fall back to the socket's peer address. Real oxmc.me
|
||||
// would resolve this properly (VPN/relay-aware, etc).
|
||||
lanHost: body.lanHost || remoteAddress,
|
||||
port: String(port),
|
||||
issuedAt: new Date(now).toISOString(),
|
||||
expiresAt: new Date(now + TOKEN_TTL_MS).toISOString(),
|
||||
};
|
||||
const token = signCacheToken(tokenPayload, root.privateKeyPem);
|
||||
|
||||
db.put(hostname, {
|
||||
serverId,
|
||||
hostname,
|
||||
port,
|
||||
pubkeyEd25519,
|
||||
tlsSpkiSha256,
|
||||
lastSeenAt: now,
|
||||
});
|
||||
|
||||
console.log(`[register] ${hostname} -> serverId=${serverId}, expires ${tokenPayload.expiresAt}`);
|
||||
sendJson(res, 200, { serverId, token, expiresAt: tokenPayload.expiresAt });
|
||||
}
|
||||
|
||||
async function handleLookup(req, res) {
|
||||
// Dev shortcut: real oxmc.me would match the caller's public IP against a
|
||||
// registered cache server's public IP. Locally everything's on loopback,
|
||||
// so this just hands back whichever server registered most recently —
|
||||
// fine for exercising the protocol, not a NAT-matching implementation.
|
||||
const row = db.mostRecentlySeen();
|
||||
if (!row) return sendJson(res, 200, { available: false });
|
||||
|
||||
const now = Date.now();
|
||||
const tokenPayload = {
|
||||
serverId: row.serverId,
|
||||
pubkeyEd25519: row.pubkeyEd25519,
|
||||
tlsSpkiSha256: row.tlsSpkiSha256,
|
||||
hostname: row.hostname,
|
||||
lanHost: row.hostname,
|
||||
port: String(row.port || 8443),
|
||||
issuedAt: new Date(now).toISOString(),
|
||||
expiresAt: new Date(now + TOKEN_TTL_MS).toISOString(),
|
||||
};
|
||||
const token = signCacheToken(tokenPayload, root.privateKeyPem);
|
||||
sendJson(res, 200, { available: true, token });
|
||||
}
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
const url = new URL(req.url, "http://placeholder");
|
||||
if (req.method === "POST" && url.pathname === "/apis/aosp/cache/register") {
|
||||
return void handleRegister(req, res).catch((err) => sendJson(res, 500, { error: err.message }));
|
||||
}
|
||||
if (req.method === "GET" && url.pathname === "/apis/aosp/cache/lookup") {
|
||||
return void handleLookup(req, res).catch((err) => sendJson(res, 500, { error: err.message }));
|
||||
}
|
||||
if (req.method === "GET" && url.pathname === "/health") {
|
||||
return sendJson(res, 200, { ok: true });
|
||||
}
|
||||
sendJson(res, 404, { error: "not found" });
|
||||
});
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`pawletcache-dev-registry listening on http://localhost:${PORT}`);
|
||||
console.log(`Enrollment token(s): ${[...ENROLLMENT_TOKENS].join(", ")}`);
|
||||
console.log(`Token TTL: ${TOKEN_TTL_MS}ms`);
|
||||
console.log("");
|
||||
console.log("Root public key (paste into Constants.CACHE_TRUST_ROOT_PUBKEY_BASE64 for local device testing):");
|
||||
console.log(pemToBase64Spki(root.publicKeyPem));
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
const { generateKeyPairSync, sign: edSign, createPrivateKey } = require("node:crypto");
|
||||
const { mkdirSync, existsSync, readFileSync, writeFileSync } = require("node:fs");
|
||||
const { join } = require("node:path");
|
||||
|
||||
/**
|
||||
* The dev registry's own root Ed25519 identity — stands in for the real
|
||||
* oxmc.me signing key. Generated once, persisted, printed at startup so you
|
||||
* can paste the public half into Constants.CACHE_TRUST_ROOT_PUBKEY_BASE64
|
||||
* for local device/emulator testing. The private half never leaves this
|
||||
* process — same shape as the real deployment, just self-issued.
|
||||
*/
|
||||
function loadOrCreateRootKey(dir) {
|
||||
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
const pubPath = join(dir, "root_ed25519.pub.pem");
|
||||
const privPath = join(dir, "root_ed25519.priv.pem");
|
||||
|
||||
if (existsSync(pubPath) && existsSync(privPath)) {
|
||||
return {
|
||||
publicKeyPem: readFileSync(pubPath, "utf8"),
|
||||
privateKeyPem: readFileSync(privPath, "utf8"),
|
||||
};
|
||||
}
|
||||
|
||||
const { publicKey, privateKey } = generateKeyPairSync("ed25519", {
|
||||
publicKeyEncoding: { type: "spki", format: "pem" },
|
||||
privateKeyEncoding: { type: "pkcs8", format: "pem" },
|
||||
});
|
||||
writeFileSync(pubPath, publicKey, { mode: 0o644 });
|
||||
writeFileSync(privPath, privateKey, { mode: 0o600 });
|
||||
return { publicKeyPem: publicKey, privateKeyPem: privateKey };
|
||||
}
|
||||
|
||||
/** Deterministic key order — must match CacheTokenVerifier.kt's canonicalize(). */
|
||||
function canonicalize(obj) {
|
||||
return JSON.stringify(obj, Object.keys(obj).sort());
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs a CacheToken payload and wraps it in the wire envelope:
|
||||
* base64( JSON.stringify({ payload, signature }) ).
|
||||
*/
|
||||
function signCacheToken(payload, rootPrivateKeyPem) {
|
||||
const signature = edSign(null, Buffer.from(canonicalize(payload)), createPrivateKey(rootPrivateKeyPem));
|
||||
const envelope = { payload, signature: signature.toString("base64") };
|
||||
return Buffer.from(JSON.stringify(envelope), "utf8").toString("base64");
|
||||
}
|
||||
|
||||
function pemToBase64Spki(pem) {
|
||||
return pem
|
||||
.replace(/-----BEGIN PUBLIC KEY-----/, "")
|
||||
.replace(/-----END PUBLIC KEY-----/, "")
|
||||
.replace(/\s+/g, "");
|
||||
}
|
||||
|
||||
module.exports = { loadOrCreateRootKey, signCacheToken, canonicalize, pemToBase64Spki };
|
||||
Generated
+151
@@ -0,0 +1,151 @@
|
||||
{
|
||||
"name": "pawletcache-server",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pawletcache-server",
|
||||
"version": "1.0.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"bonjour-service": "^1.2.1",
|
||||
"js-yaml": "^4.1.0",
|
||||
"selfsigned": "^2.4.1"
|
||||
},
|
||||
"bin": {
|
||||
"pawletcache-server": "src/index.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@leichtgewicht/ip-codec": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz",
|
||||
"integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "26.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz",
|
||||
"integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~8.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node-forge": {
|
||||
"version": "1.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/node-forge/-/node-forge-1.3.14.tgz",
|
||||
"integrity": "sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/argparse": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/bonjour-service": {
|
||||
"version": "1.4.3",
|
||||
"resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.4.3.tgz",
|
||||
"integrity": "sha512-2Kd5UYlFUVgAKMTyuBLl6w49wqfOnbxHqmuH0oCl/n7TfAikR0zoowNOP5BU4dfXmm+Vr9JyEN370auSMx+CNg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"multicast-dns": "^7.2.5"
|
||||
}
|
||||
},
|
||||
"node_modules/dns-packet": {
|
||||
"version": "5.6.1",
|
||||
"resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz",
|
||||
"integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@leichtgewicht/ip-codec": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
|
||||
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/puzrin"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/nodeca"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"node_modules/multicast-dns": {
|
||||
"version": "7.2.5",
|
||||
"resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz",
|
||||
"integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dns-packet": "^5.2.2",
|
||||
"thunky": "^1.0.2"
|
||||
},
|
||||
"bin": {
|
||||
"multicast-dns": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/node-forge": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz",
|
||||
"integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==",
|
||||
"license": "(BSD-3-Clause OR GPL-2.0)",
|
||||
"engines": {
|
||||
"node": ">= 6.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/selfsigned": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-2.4.1.tgz",
|
||||
"integrity": "sha512-th5B4L2U+eGLq1TVh7zNRGBapioSORUeymIydxgFpwww9d2qyKvtuPU2jJuHvYAwwqi2Y596QBL3eEqcPEYL8Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node-forge": "^1.3.0",
|
||||
"node-forge": "^1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/thunky": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz",
|
||||
"integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
|
||||
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "pawletcache-server",
|
||||
"version": "1.0.0",
|
||||
"description": "Local network content cache for PawletOS device updates (BgUpd component assets).",
|
||||
"license": "Apache-2.0",
|
||||
"type": "commonjs",
|
||||
"main": "src/index.js",
|
||||
"bin": {
|
||||
"pawletcache-server": "src/index.js"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node src/index.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"dependencies": {
|
||||
"bonjour-service": "^1.2.1",
|
||||
"js-yaml": "^4.1.0",
|
||||
"selfsigned": "^2.4.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env node
|
||||
// Throwaway HTTPS origin server for scripts/smoke-test.sh — cache.js only
|
||||
// proxies https:// origins, so the smoke test needs *something* self-signed
|
||||
// to fetch-through, not a real oxmc.me dependency. Not used outside the
|
||||
// smoke test.
|
||||
const { createServer } = require("node:https");
|
||||
const selfsigned = require("selfsigned");
|
||||
|
||||
const port = Number(process.argv[2] || 9443);
|
||||
const body = Buffer.from("hello from dummy origin\n");
|
||||
|
||||
const pems = selfsigned.generate([{ name: "commonName", value: "localhost" }], { days: 1 });
|
||||
|
||||
createServer({ cert: pems.cert, key: pems.private }, (req, res) => {
|
||||
res.writeHead(200, { "content-type": "text/plain", "content-length": body.length });
|
||||
res.end(body);
|
||||
}).listen(port, () => {
|
||||
console.log(`dummy-origin listening on https://localhost:${port}`);
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
#!/usr/bin/env bash
|
||||
# End-to-end local smoke test: dev-registry + pawletcache-server + a throwaway
|
||||
# HTTPS origin, wired together, verifying register -> signed token -> asset
|
||||
# fetch-through -> disk cache actually works. No real oxmc.me dependency.
|
||||
#
|
||||
# Requires: `npm install` already run in pawletcache-server/ (bonjour-service,
|
||||
# js-yaml, selfsigned). dev-registry/ has zero deps, nothing to install there.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.." # pawletcache-server/
|
||||
|
||||
ORIGIN_PORT=9443
|
||||
REGISTRY_PORT=4100
|
||||
CACHE_PORT=8543
|
||||
ENROLLMENT_TOKEN=smoke-test-token
|
||||
# Path handling here is fussy on Windows+Git-Bash: native Windows node.exe
|
||||
# doesn't understand MSYS-style POSIX paths at all (not just /tmp — `pwd`'s
|
||||
# /c/Users/... gets misread as literal drive-root segments, e.g. resolves to
|
||||
# C:\c\Users\...). `pwd -W` gives the Windows-style form (C:/Users/...),
|
||||
# which both node.exe and Bash/`ls` resolve to the same real location — use
|
||||
# that for anything written into a file Node will read a path back out of.
|
||||
# (On real POSIX systems `pwd -W` doesn't exist; fall back to plain pwd.)
|
||||
WORKDIR="$(pwd -W 2>/dev/null || pwd)/.smoke-test-tmp-$$"
|
||||
mkdir -p "$WORKDIR"
|
||||
|
||||
PIDS=()
|
||||
cleanup() {
|
||||
for pid in "${PIDS[@]:-}"; do kill "$pid" 2>/dev/null || true; done
|
||||
rm -rf "$WORKDIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
pass() { echo "PASS: $1"; }
|
||||
fail() { echo "FAIL: $1"; cleanup; exit 1; }
|
||||
|
||||
wait_for() {
|
||||
local url=$1 tries=${2:-30}
|
||||
for _ in $(seq 1 "$tries"); do
|
||||
curl -sk -o /dev/null "$url" && return 0
|
||||
sleep 0.5
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
echo "== starting dummy HTTPS origin on :$ORIGIN_PORT =="
|
||||
node scripts/dummy-origin.js "$ORIGIN_PORT" > "$WORKDIR/origin.log" 2>&1 &
|
||||
PIDS+=($!)
|
||||
wait_for "https://localhost:$ORIGIN_PORT/" || fail "dummy origin never came up"
|
||||
|
||||
echo "== starting dev-registry on :$REGISTRY_PORT =="
|
||||
DEV_REGISTRY_PORT=$REGISTRY_PORT \
|
||||
DEV_REGISTRY_DATA_DIR="$WORKDIR/registry-data" \
|
||||
DEV_ENROLLMENT_TOKENS=$ENROLLMENT_TOKEN \
|
||||
DEV_TOKEN_TTL_MS=600000 \
|
||||
node dev-registry/src/index.js > "$WORKDIR/registry.log" 2>&1 &
|
||||
PIDS+=($!)
|
||||
wait_for "http://localhost:$REGISTRY_PORT/health" || fail "dev-registry never came up"
|
||||
pass "dev-registry up"
|
||||
|
||||
echo "== writing pawletcache-server config =="
|
||||
cat > "$WORKDIR/config.yml" <<EOF
|
||||
hostname: localhost
|
||||
listenAddr: "0.0.0.0"
|
||||
listenPort: $CACHE_PORT
|
||||
storageDir: $WORKDIR/assets
|
||||
identityDir: $WORKDIR/identity
|
||||
centralUrl: http://localhost:$REGISTRY_PORT
|
||||
enrollmentToken: $ENROLLMENT_TOKEN
|
||||
allowedOrigins:
|
||||
- localhost
|
||||
EOF
|
||||
|
||||
echo "== starting pawletcache-server on :$CACHE_PORT =="
|
||||
# dummy-origin.js is self-signed on purpose (see its header comment) — cache.js
|
||||
# rightly rejects that for a real origin, so this smoke test alone tells
|
||||
# Node's fetch() to skip TLS verification. Never do this outside a scoped
|
||||
# local test — cache.js's own code has no such override.
|
||||
NODE_TLS_REJECT_UNAUTHORIZED=0 node src/index.js "$WORKDIR/config.yml" > "$WORKDIR/cache-server.log" 2>&1 &
|
||||
PIDS+=($!)
|
||||
wait_for "https://localhost:$CACHE_PORT/asset" || fail "pawletcache-server never came up"
|
||||
|
||||
echo "== waiting for registration to land in dev-registry =="
|
||||
registered=false
|
||||
for _ in $(seq 1 20); do
|
||||
lookup=$(curl -s "http://localhost:$REGISTRY_PORT/apis/aosp/cache/lookup")
|
||||
if echo "$lookup" | grep -q '"available":true'; then
|
||||
registered=true
|
||||
break
|
||||
fi
|
||||
sleep 0.5
|
||||
done
|
||||
[ "$registered" = true ] || fail "pawletcache-server never registered with dev-registry (see $WORKDIR/cache-server.log, $WORKDIR/registry.log)"
|
||||
pass "pawletcache-server registered, dev-registry issued a signed token"
|
||||
|
||||
echo "== verifying the CacheToken carries the right lanHost/port =="
|
||||
token=$(node -e "console.log(JSON.parse(process.argv[1]).token)" "$lookup")
|
||||
node -e "
|
||||
const env = JSON.parse(Buffer.from(process.argv[1], 'base64').toString('utf8'));
|
||||
const p = env.payload;
|
||||
if (p.hostname !== 'localhost') throw new Error('hostname mismatch: ' + p.hostname);
|
||||
if (p.port !== String($CACHE_PORT)) throw new Error('port mismatch: ' + p.port);
|
||||
if (!p.tlsSpkiSha256) throw new Error('missing tlsSpkiSha256');
|
||||
console.log('token payload OK:', JSON.stringify(p));
|
||||
" "$token" || fail "CacheToken payload didn't look right"
|
||||
pass "CacheToken payload shape OK"
|
||||
|
||||
echo "== fetching an asset through the cache (miss, then hit) =="
|
||||
origin_url="https%3A%2F%2Flocalhost%3A${ORIGIN_PORT}%2Ffile"
|
||||
body1=$(curl -sk "https://localhost:$CACHE_PORT/asset?url=$origin_url")
|
||||
[ "$body1" = "hello from dummy origin" ] || fail "first fetch-through returned wrong body: '$body1'"
|
||||
pass "cache miss fetch-through returned correct content"
|
||||
|
||||
body2=$(curl -sk "https://localhost:$CACHE_PORT/asset?url=$origin_url")
|
||||
[ "$body2" = "hello from dummy origin" ] || fail "second (cached) fetch returned wrong body: '$body2'"
|
||||
pass "cache hit returned correct content"
|
||||
|
||||
[ -n "$(ls -A "$WORKDIR/assets" 2>/dev/null)" ] || fail "nothing landed in storageDir — cache didn't persist to disk"
|
||||
pass "asset persisted to disk cache"
|
||||
|
||||
echo "== verifying the SSRF allowlist actually blocks non-allowlisted origins =="
|
||||
status=$(curl -sk -o /dev/null -w '%{http_code}' "https://localhost:$CACHE_PORT/asset?url=https%3A%2F%2Fevil.example%2Ffile")
|
||||
[ "$status" = "403" ] || fail "expected 403 for a non-allowlisted origin, got $status"
|
||||
pass "non-allowlisted origin correctly rejected (403)"
|
||||
|
||||
echo ""
|
||||
echo "ALL CHECKS PASSED"
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
const { createHash } = require("node:crypto");
|
||||
const { mkdirSync, existsSync, statSync, renameSync, unlinkSync, createWriteStream, createReadStream } = require("node:fs");
|
||||
const { join } = require("node:path");
|
||||
const { Readable } = require("node:stream");
|
||||
|
||||
/** Thrown for anything that should map to an HTTP error at the server layer. */
|
||||
class CacheError extends Error {
|
||||
constructor(status, message) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dumb fetch-through cache keyed by the origin URL's SHA-256. Deliberately
|
||||
* has no knowledge of BgUpd's manifest format — it just proxies+stores GET
|
||||
* responses for allowlisted origin hosts, which is what lets a future OTA
|
||||
* or media resolver reuse this same daemon.
|
||||
*/
|
||||
class AssetCache {
|
||||
constructor(cfg) {
|
||||
this.storageDir = cfg.storageDir;
|
||||
this.allowedOrigins = new Set(cfg.allowedOrigins);
|
||||
mkdirSync(this.storageDir, { recursive: true, mode: 0o755 });
|
||||
this.inFlight = new Map(); // url -> Promise, collapses concurrent misses
|
||||
}
|
||||
|
||||
/** @returns {Promise<{path: string, size: number}>} */
|
||||
async get(originUrl) {
|
||||
this.validateOrigin(originUrl);
|
||||
const finalPath = this.pathFor(originUrl);
|
||||
|
||||
if (existsSync(finalPath)) {
|
||||
return { path: finalPath, size: statSync(finalPath).size };
|
||||
}
|
||||
|
||||
const existing = this.inFlight.get(originUrl);
|
||||
if (existing) return existing;
|
||||
|
||||
const promise = this.fetchThrough(originUrl, finalPath).finally(() => {
|
||||
this.inFlight.delete(originUrl);
|
||||
});
|
||||
this.inFlight.set(originUrl, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
validateOrigin(originUrl) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(originUrl);
|
||||
} catch {
|
||||
throw new CacheError(400, "invalid url");
|
||||
}
|
||||
if (parsed.protocol !== "https:") {
|
||||
throw new CacheError(400, "only https origins are proxied");
|
||||
}
|
||||
if (!this.allowedOrigins.has(parsed.hostname)) {
|
||||
throw new CacheError(403, `origin ${parsed.hostname} is not allowlisted`);
|
||||
}
|
||||
}
|
||||
|
||||
pathFor(originUrl) {
|
||||
const key = createHash("sha256").update(originUrl).digest("hex");
|
||||
return join(this.storageDir, key);
|
||||
}
|
||||
|
||||
async fetchThrough(originUrl, finalPath) {
|
||||
const res = await fetch(originUrl);
|
||||
if (!res.ok || !res.body) {
|
||||
throw new CacheError(502, `origin returned HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
const tmpPath = `${finalPath}.part-${process.pid}-${Date.now()}`;
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
const out = createWriteStream(tmpPath);
|
||||
Readable.fromWeb(res.body).pipe(out);
|
||||
out.on("finish", resolve);
|
||||
out.on("error", reject);
|
||||
});
|
||||
} catch (err) {
|
||||
try {
|
||||
unlinkSync(tmpPath);
|
||||
} catch {
|
||||
// best-effort cleanup
|
||||
}
|
||||
throw new CacheError(502, `failed downloading origin: ${err.message}`);
|
||||
}
|
||||
|
||||
renameSync(tmpPath, finalPath);
|
||||
return { path: finalPath, size: statSync(finalPath).size };
|
||||
}
|
||||
|
||||
/** For the request handler to stream the cached file back. */
|
||||
readStream(path) {
|
||||
return createReadStream(path);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { AssetCache, CacheError };
|
||||
@@ -0,0 +1,31 @@
|
||||
const { readFileSync } = require("node:fs");
|
||||
const yaml = require("js-yaml");
|
||||
|
||||
const DEFAULTS = {
|
||||
storageDir: "/var/lib/pawletcache/assets",
|
||||
identityDir: "/var/lib/pawletcache/identity",
|
||||
listenAddr: "0.0.0.0",
|
||||
listenPort: 8443,
|
||||
centralUrl: "https://oxmc.me",
|
||||
allowedOrigins: ["oxmc.me"],
|
||||
};
|
||||
|
||||
/** Loads /etc/pawletcache/config.yml (or the given path). */
|
||||
function loadConfig(path) {
|
||||
const raw = readFileSync(path, "utf8");
|
||||
const parsed = yaml.load(raw) ?? {};
|
||||
const cfg = { ...DEFAULTS, ...parsed };
|
||||
|
||||
if (!cfg.hostname) {
|
||||
throw new Error("config: hostname is required");
|
||||
}
|
||||
if (!cfg.enrollmentToken) {
|
||||
throw new Error("config: enrollmentToken is required");
|
||||
}
|
||||
if (!Array.isArray(cfg.allowedOrigins) || cfg.allowedOrigins.length === 0) {
|
||||
throw new Error("config: allowedOrigins must not be empty");
|
||||
}
|
||||
return cfg;
|
||||
}
|
||||
|
||||
module.exports = { loadConfig };
|
||||
@@ -0,0 +1,71 @@
|
||||
const { generateKeyPairSync, X509Certificate, createHash } = require("node:crypto");
|
||||
const { mkdirSync, readFileSync, writeFileSync, existsSync } = require("node:fs");
|
||||
const { join } = require("node:path");
|
||||
const selfsigned = require("selfsigned");
|
||||
|
||||
/**
|
||||
* This server's long-lived key material:
|
||||
* - an Ed25519 keypair, whose public half is what the central registry's
|
||||
* signed CacheToken attests to (see registration.js)
|
||||
* - a self-signed TLS keypair the asset HTTPS server terminates with
|
||||
*
|
||||
* Devices pin to the TLS cert's SPKI SHA-256 fingerprint carried inside the
|
||||
* signed CacheToken rather than trusting a CA chain, so the TLS cert never
|
||||
* needs to be "real" beyond having a stable, fingerprint-able public key.
|
||||
*/
|
||||
function loadOrCreateIdentity(dir, hostname) {
|
||||
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
|
||||
const ed25519 = loadOrCreateEd25519(join(dir, "identity_ed25519"));
|
||||
const tls = loadOrCreateTlsCert(join(dir, "tls.crt"), join(dir, "tls.key"), hostname);
|
||||
|
||||
const cert = new X509Certificate(tls.cert);
|
||||
const spkiDer = cert.publicKey.export({ type: "spki", format: "der" });
|
||||
const tlsSpkiSha256 = createHash("sha256").update(spkiDer).digest();
|
||||
|
||||
return {
|
||||
ed25519PublicKey: ed25519.publicKey,
|
||||
ed25519PrivateKey: ed25519.privateKey,
|
||||
tlsCert: tls.cert,
|
||||
tlsKey: tls.key,
|
||||
tlsSpkiSha256, // Buffer — base64 this when sending to the central registry
|
||||
};
|
||||
}
|
||||
|
||||
function loadOrCreateEd25519(basePath) {
|
||||
const pubPath = `${basePath}.pub.pem`;
|
||||
const privPath = `${basePath}.priv.pem`;
|
||||
if (existsSync(pubPath) && existsSync(privPath)) {
|
||||
return {
|
||||
publicKey: readFileSync(pubPath, "utf8"),
|
||||
privateKey: readFileSync(privPath, "utf8"),
|
||||
};
|
||||
}
|
||||
|
||||
const { publicKey, privateKey } = generateKeyPairSync("ed25519", {
|
||||
publicKeyEncoding: { type: "spki", format: "pem" },
|
||||
privateKeyEncoding: { type: "pkcs8", format: "pem" },
|
||||
});
|
||||
writeFileSync(pubPath, publicKey, { mode: 0o644 });
|
||||
writeFileSync(privPath, privateKey, { mode: 0o600 });
|
||||
return { publicKey, privateKey };
|
||||
}
|
||||
|
||||
function loadOrCreateTlsCert(certPath, keyPath, hostname) {
|
||||
if (existsSync(certPath) && existsSync(keyPath)) {
|
||||
return { cert: readFileSync(certPath, "utf8"), key: readFileSync(keyPath, "utf8") };
|
||||
}
|
||||
|
||||
const attrs = [{ name: "commonName", value: hostname }];
|
||||
const pems = selfsigned.generate(attrs, {
|
||||
days: 3650,
|
||||
algorithm: "sha256",
|
||||
keySize: 2048,
|
||||
extensions: [{ name: "subjectAltName", altNames: [{ type: 2, value: hostname }] }],
|
||||
});
|
||||
writeFileSync(certPath, pems.cert, { mode: 0o644 });
|
||||
writeFileSync(keyPath, pems.private, { mode: 0o600 });
|
||||
return { cert: pems.cert, key: pems.private };
|
||||
}
|
||||
|
||||
module.exports = { loadOrCreateIdentity };
|
||||
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env node
|
||||
const { loadConfig } = require("./config.js");
|
||||
const { loadOrCreateIdentity } = require("./identity.js");
|
||||
const { startRegistrationLoop } = require("./registration.js");
|
||||
const { startMdnsAdvertiser } = require("./mdns.js");
|
||||
const { AssetCache } = require("./cache.js");
|
||||
const { startAssetServer } = require("./server.js");
|
||||
|
||||
function main() {
|
||||
const configPath = process.argv[2] || "/etc/pawletcache/config.yml";
|
||||
const cfg = loadConfig(configPath);
|
||||
|
||||
const identity = loadOrCreateIdentity(cfg.identityDir, cfg.hostname);
|
||||
const cache = new AssetCache(cfg);
|
||||
|
||||
const server = startAssetServer(cfg, identity, cache);
|
||||
|
||||
const mdns = startMdnsAdvertiser(cfg);
|
||||
startRegistrationLoop(cfg, identity, (token) => {
|
||||
mdns.update(token);
|
||||
console.log("[registration] token refreshed, mDNS advertisement updated");
|
||||
});
|
||||
|
||||
const shutdown = () => {
|
||||
console.log("shutting down");
|
||||
mdns.stop();
|
||||
server.close();
|
||||
process.exit(0);
|
||||
};
|
||||
process.on("SIGINT", shutdown);
|
||||
process.on("SIGTERM", shutdown);
|
||||
}
|
||||
|
||||
main();
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
const { Bonjour } = require("bonjour-service");
|
||||
|
||||
const SERVICE_TYPE = "pawletcache";
|
||||
const PROTOCOL = "tcp";
|
||||
// DNS TXT limits each string to 255 bytes; split the signed token across
|
||||
// numbered keys so it survives regardless of length.
|
||||
const CHUNK_SIZE = 200;
|
||||
|
||||
/**
|
||||
* Advertises this cache server on the LAN as `_pawletcache._tcp.local.`.
|
||||
* The TXT record carries the signed CacheToken so a device can verify this
|
||||
* server's identity purely from the mDNS response, no central lookup
|
||||
* needed (see pawletcache-server/README.md's trust model).
|
||||
*
|
||||
* Call `update(token)` whenever registration.js hands back a fresh token
|
||||
* (e.g. after renewal) to re-publish without a restart.
|
||||
*/
|
||||
function startMdnsAdvertiser(cfg) {
|
||||
const bonjour = new Bonjour();
|
||||
let service = null;
|
||||
|
||||
function publish(token) {
|
||||
if (service) {
|
||||
service.stop(() => {});
|
||||
}
|
||||
service = bonjour.publish({
|
||||
name: cfg.hostname,
|
||||
type: SERVICE_TYPE,
|
||||
protocol: PROTOCOL,
|
||||
port: cfg.listenPort,
|
||||
txt: chunkToken(token),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
update(token) {
|
||||
publish(token);
|
||||
},
|
||||
stop() {
|
||||
if (service) service.stop(() => {});
|
||||
bonjour.destroy();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function chunkToken(token) {
|
||||
const chunks = [];
|
||||
for (let i = 0; i < token.length; i += CHUNK_SIZE) {
|
||||
chunks.push(token.slice(i, i + CHUNK_SIZE));
|
||||
}
|
||||
const txt = { chunks: String(chunks.length) };
|
||||
chunks.forEach((chunk, i) => {
|
||||
txt[`token${i}`] = chunk;
|
||||
});
|
||||
return txt;
|
||||
}
|
||||
|
||||
module.exports = { startMdnsAdvertiser };
|
||||
@@ -0,0 +1,104 @@
|
||||
const { sign: edSign, createPrivateKey } = require("node:crypto");
|
||||
const { readFileSync, writeFileSync, existsSync } = require("node:fs");
|
||||
const { join } = require("node:path");
|
||||
|
||||
const TOKEN_FILE = "token.json";
|
||||
const MIN_RETRY_MS = 5_000;
|
||||
const MAX_RETRY_MS = 10 * 60_000;
|
||||
|
||||
/**
|
||||
* Registers (or renews) with the central registry (oxmc.me) and keeps the
|
||||
* signed CacheToken fresh in the background. The token is what devices
|
||||
* verify locally (offline, no central round-trip) before trusting this
|
||||
* server via mDNS or central lookup — see pawletcache-server/README.md.
|
||||
*
|
||||
* @param {object} cfg parsed config.yml
|
||||
* @param {object} identity from identity.js
|
||||
* @param {(token: string, expiresAt: string) => void} onToken called with a
|
||||
* fresh signed token every time registration/renewal succeeds
|
||||
*/
|
||||
function startRegistrationLoop(cfg, identity, onToken) {
|
||||
const tokenPath = join(cfg.identityDir, TOKEN_FILE);
|
||||
|
||||
// Serve a previously-persisted token immediately (offline/LAN-only boot),
|
||||
// while a renewal attempt runs in the background.
|
||||
const cached = loadPersistedToken(tokenPath);
|
||||
if (cached) onToken(cached.token, cached.expiresAt);
|
||||
|
||||
let retryMs = MIN_RETRY_MS;
|
||||
const attempt = async () => {
|
||||
try {
|
||||
const { token, expiresAt } = await register(cfg, identity);
|
||||
writeFileSync(tokenPath, JSON.stringify({ token, expiresAt }, null, 2), { mode: 0o600 });
|
||||
onToken(token, expiresAt);
|
||||
retryMs = MIN_RETRY_MS;
|
||||
|
||||
const ttlMs = new Date(expiresAt).getTime() - Date.now();
|
||||
const renewInMs = Math.max(ttlMs * 0.8, MIN_RETRY_MS);
|
||||
setTimeout(attempt, renewInMs);
|
||||
} catch (err) {
|
||||
console.error(`[registration] failed, retrying in ${Math.round(retryMs / 1000)}s:`, err.message);
|
||||
setTimeout(attempt, retryMs);
|
||||
retryMs = Math.min(retryMs * 2, MAX_RETRY_MS);
|
||||
}
|
||||
};
|
||||
attempt();
|
||||
}
|
||||
|
||||
function loadPersistedToken(tokenPath) {
|
||||
if (!existsSync(tokenPath)) return null;
|
||||
try {
|
||||
const { token, expiresAt } = JSON.parse(readFileSync(tokenPath, "utf8"));
|
||||
if (new Date(expiresAt).getTime() <= Date.now()) return null; // expired, don't hand out
|
||||
return { token, expiresAt };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function register(cfg, identity) {
|
||||
const signedAt = new Date().toISOString();
|
||||
const payload = {
|
||||
hostname: cfg.hostname,
|
||||
port: cfg.listenPort,
|
||||
pubkeyEd25519: pemToBase64Spki(identity.ed25519PublicKey),
|
||||
tlsSpkiSha256: identity.tlsSpkiSha256.toString("base64"),
|
||||
signedAt,
|
||||
};
|
||||
// Proves continued possession of the same long-lived identity across
|
||||
// renewals — the central registry pins pubkeyEd25519 to this server's
|
||||
// server_id on first registration and expects every renewal signed by it.
|
||||
const signature = edSign(null, Buffer.from(canonicalize(payload)), createPrivateKey(identity.ed25519PrivateKey));
|
||||
|
||||
const res = await fetch(`${cfg.centralUrl}/apis/aosp/cache/register`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
...payload,
|
||||
signature: signature.toString("base64"),
|
||||
enrollmentToken: cfg.enrollmentToken,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`registry returned HTTP ${res.status}: ${await res.text().catch(() => "")}`);
|
||||
}
|
||||
const body = await res.json();
|
||||
if (!body.token || !body.expiresAt) {
|
||||
throw new Error("registry response missing token/expiresAt");
|
||||
}
|
||||
return { token: body.token, expiresAt: body.expiresAt };
|
||||
}
|
||||
|
||||
function pemToBase64Spki(pem) {
|
||||
return pem
|
||||
.replace(/-----BEGIN PUBLIC KEY-----/, "")
|
||||
.replace(/-----END PUBLIC KEY-----/, "")
|
||||
.replace(/\s+/g, "");
|
||||
}
|
||||
|
||||
/** Deterministic key order so the signature is reproducible cross-language. */
|
||||
function canonicalize(obj) {
|
||||
return JSON.stringify(obj, Object.keys(obj).sort());
|
||||
}
|
||||
|
||||
module.exports = { startRegistrationLoop };
|
||||
@@ -0,0 +1,47 @@
|
||||
const { createServer } = require("node:https");
|
||||
const { CacheError } = require("./cache.js");
|
||||
|
||||
/**
|
||||
* HTTPS asset server. One route: `GET /asset?url=<origin url>`. TLS
|
||||
* terminates with the self-signed identity cert — devices trust it via the
|
||||
* SPKI fingerprint pinned in the signed CacheToken, not a CA chain, so
|
||||
* there's nothing more to configure here.
|
||||
*/
|
||||
function startAssetServer(cfg, identity, cache) {
|
||||
const server = createServer({ cert: identity.tlsCert, key: identity.tlsKey }, (req, res) => {
|
||||
handleRequest(req, res, cache).catch((err) => {
|
||||
const status = err instanceof CacheError ? err.status : 500;
|
||||
if (!(err instanceof CacheError)) console.error("[server] unhandled error:", err);
|
||||
res.writeHead(status, { "content-type": "text/plain" }).end(err.message);
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(cfg.listenPort, cfg.listenAddr, () => {
|
||||
console.log(`[server] listening on https://${cfg.listenAddr}:${cfg.listenPort}`);
|
||||
});
|
||||
return server;
|
||||
}
|
||||
|
||||
async function handleRequest(req, res, cache) {
|
||||
if (req.method !== "GET") {
|
||||
throw new CacheError(405, "only GET is supported");
|
||||
}
|
||||
const requestUrl = new URL(req.url, "https://placeholder");
|
||||
if (requestUrl.pathname !== "/asset") {
|
||||
throw new CacheError(404, "not found");
|
||||
}
|
||||
const originUrl = requestUrl.searchParams.get("url");
|
||||
if (!originUrl) {
|
||||
throw new CacheError(400, "missing url query parameter");
|
||||
}
|
||||
|
||||
const { path, size } = await cache.get(originUrl);
|
||||
res.writeHead(200, {
|
||||
"content-type": "application/octet-stream",
|
||||
"content-length": size,
|
||||
"cache-control": "public, max-age=31536000, immutable",
|
||||
});
|
||||
cache.readStream(path).pipe(res);
|
||||
}
|
||||
|
||||
module.exports = { startAssetServer };
|
||||
Reference in New Issue
Block a user