Add scoped peer access and reversible serving controls

This commit is contained in:
Codex committed 2026-10-11 00:25:14 +02:00
1 parent 6c994113c6
commit 492477f86a
7 files changed
+1081

No files matched your search

+138
View File
@@ -0,0 +1,138 @@
# Server peering (v1)
Status: implemented, tested in isolated local fixtures, and enabled for a local
two-instance pilot on 2026-10-10. It has **not** been deployed to the VM. See the broader design and
future ideas in [`vision/server-peering.md`](../vision/server-peering.md).
Peering is an opt-in, pull-only transfer between MangaCircus servers. It does not
exchange accounts, reading history, ratings, notes, or scraper credentials. The
publishing gateway reads a library mount, and only the existing scraper publishes
received chapters. No database server or second library writer is introduced.
## Roles and direction
The admin UI is **Settings → Peering**, and all users can open the **Peers** catalog
page. Each server may have many peers. For every peer, its admin independently sets:
- **Serve approved manga**: expose only individually checked library titles to that
peer. Off is the default and invalidates the current serving grant.
- **Allow downloads**: queue and pull chapters from that peer. Off is the default;
a verified pairing and imported grant still allow browsing its approved catalog.
- **Let readers queue**: ordinary readers may request transfers; otherwise only an
admin may queue. Browsing a permitted catalog does not disclose the local user to
the remote server.
- Upload streams, bytes/s and bytes/day, and receiving pages in flight and bytes/s.
Defaults are two in-flight pages and 2 MiB/s in each direction per peer. The
publisher's caps remain authoritative. A gateway-wide four-stream ceiling applies.
Serving and receiving are separate directions. Pairing by itself exposes no title.
## Deployment
The optional `peer-gateway` Compose profile publishes port 8093 on **127.0.0.1** by
default. It uses the same image as reader/scraper, a read-only library mount, a
read-only peering policy/identity mount, and its own writable usage-counter mount.
The gateway needs an external-facing Docker network for its host port to work on
Docker Desktop; it also joins the private backend. It has no reader account database,
scraper queue, Docker socket, or writable library mount. It must never be scaled
past one instance for the same usage-counter directory.
1. Build the image and start the normal stack as documented in
[`compose-deployment.md`](compose-deployment.md). Generate the local certificate
in **Settings → Peering**. Keep `/data/peering/identity.key` private and backed up.
2. Start the optional service with `docker compose --profile peering up -d
peer-gateway`. Verify `docker compose --profile peering ps` shows healthy and
`https://127.0.0.1:8093/health` responds. `/health` is deliberately unauthenticated
and contains only `{ "ok": true }`.
3. Give the other server a reachable HTTPS endpoint for port 8093. A private
Tailscale TCP forward is recommended for the pilot; the default loopback binding
is not remotely reachable. Do not expose port 8093 on the public internet merely
to make pairing work. Use a stable tailnet DNS name, not a changing IP. Keep
scraper (8091) and Sentinel (8092) private.
The `PEER_BIND`, `PEER_PORT`, `PEER_DATA_PATH`, and `PEER_USAGE_PATH` Compose variables
control the bind and persistent paths. The gateway's internal port remains 8093.
OpenSSL is bundled in the app image for one-time P-256 self-signed certificate
generation. Each certificate is valid for one year. Expired/replaced identities
require administrator-supervised re-pairing; there is no automatic key rotation yet.
## Pairing and transfer
1. Each admin generates an identity, creates a 48-hour invitation, and exchanges the
JSON outside the app. The JSON contains a public certificate, SHA-256 fingerprint,
URL, invitation ID and one-use code—**not** the private key. Compare the
fingerprints through a separate trusted channel.
2. Both admins import the other's invitation and press **Test & verify pairing**.
The HTTPS handshake presents client and server certificates, checks their exact
pins and dates, and consumes the one-use invitation code. Both sides must probe.
3. A publisher checks specific titles under that peer, enables **Serve**, issues a
90-day random grant, and sends the token privately to the receiving admin. The
receiving admin imports the grant and enables **Allow downloads**. Reverse
direction needs its own share selection and grant. The server stores only a hash
of a grant it issued; the receiver stores its imported token server-side.
4. A local user browses the catalog, selects missing/newest/last-five or an exact
chapter, and queues it through the normal scraper. The peer adapter downloads up
to the configured pages in flight, validates size and SHA-256, resumes a partial
page with HTTP Range, checkpoints between batches, and atomically publishes each
complete chapter into the ordinary library. Existing-chapter policy is applied
before download. Partial transfers remain in the hidden scraper work folder.
The publisher sees the receiving **server** certificate and transfer requests, not
the human reader. It can still observe requested titles, times and byte counts.
## Peer v1 HTTP interface
All content endpoints require TLS 1.3 client certificate pinning, a paired/serving
peer, and a currently valid certificate-bound bearer grant. There is no insecure
HTTP fallback and no path-based file browsing.
| Request | Response |
| --- | --- |
| `GET /peer/v1/hello` with one-use invitation headers | Protocol version and server identity fingerprint; no catalog |
| `GET /peer/v1/catalog?cursor=N` | Up to 50 approved opaque title IDs, title/year/status, next cursor |
| `GET /peer/v1/manga/{id}?cursor=N` | Up to 100 complete chapter IDs and light metadata |
| `GET /peer/v1/manga/{id}/chapters/{id}` | Ordered page IDs, MIME, byte size, SHA-256 and ETag |
| `GET /peer/v1/blobs/{id}` | One image, optional `Range: bytes=N-` and `If-Match`; 206 on resume |
Unknown/unshared IDs return 404. Revoked serving returns 403; bad/missing grants
return 401; caps return 429 and `Retry-After`. No source-site URL, local filesystem
path, or user record is sent in protocol responses. Individual complete chapter
manifests are discovered from final `chapter.json` folders; an unfinished folder is
not shared. Catalog and chapter lists are short-lived in-memory caches. Turning off
serving blocks subsequent requests immediately, though an already streaming image
may finish its current chunk/response.
The initial implementation sends original image bytes; it does not transcode them.
See [`vision/server-peering-compression-test.md`](../vision/server-peering-compression-test.md)
for the measured reason. The transport accepts a small amount of controlled page
parallelism, not unbounded connections.
## Tests and safe operation
`npm run test:peer` creates disposable A/B identities, gateways and libraries and
tests mTLS pairing, no-cert/bad-grant rejection, private catalog, per-title sharing,
manifest hashes, a resumed range response, scraper publication, duplicate skip and
revocation. `npm test` includes it. An isolated Compose acceptance is:
```powershell
$env:APP_IMAGE = 'mangacircus:local' # replace with the image just built
$env:QA_PORT = '8188'
$env:QA_PEER_PORT = '8193'
node tools/test-compose.js --peer-gateway --peer-ui
```
It makes disposable library/data, four containers, a certificate and a browser
session, then tears them down. It never mounts the live library/queue.
For live rollout, wait for a scraper checkpoint, back up the library and data
(including the peering identity, policy, outbound grants, gateway usage and hidden
work folders), and deploy reader/scraper/gateway from one immutable image digest.
There must remain exactly one queue-owning scraper and one reader database writer.
Peering is optional: stop the `peer-gateway` profile to close incoming service
without affecting reading or existing local downloads. Do not delete the identity
to recover from a network error; that would break every peer pin.
Current limits: no automatic certificate renewal, invitation cancellation UI,
peer cover-image endpoint, multi-peer performance pilot, VM deployment, or public
internet exposure review. The gateway has no automated hoster-interaction logic;
source scraping remains subject to the existing source-safety policy.
+110
View File
@@ -0,0 +1,110 @@
'use strict';
const fs = require('node:fs').promises;
const path = require('node:path');
const crypto = require('node:crypto');
const { PeeringState, requirePeer } = require('./state');
const client = require('./client');
const { ArchiveControlError } = require('../scraper/lib/control');
const { selectByScope } = require('../scraper/lib/scope');
const { scanLibrary, findManga, shouldDownload } = require('../scraper/lib/library-inventory');
const { publishManifest } = require('../scraper/lib/publisher');
const { writeJsonAtomic } = require('../scraper/lib/json-file');
const { sleep } = require('../scraper/lib/delay');
const dataRoot = () => path.resolve(process.env.PEER_DATA || path.join(process.env.DATA || 'data', 'peering'));
const store = () => new PeeringState(dataRoot());
const infoFor = value => {
try {
const u = new URL(String(value));
if (u.protocol !== 'peer:' || u.username || u.password || u.search || u.hash || !/^p[0-9a-f]{24}$/.test(u.hostname)
|| !/^\/m[0-9a-f]{24}$/.test(u.pathname)) return null;
const peer = requirePeer(store().load(), u.hostname);
return { source: `peer-${peer.id}`, id: `${peer.id}-${u.pathname.slice(1)}`, peerId: peer.id,
shareId: u.pathname.slice(1), title: peer.alias, url: `peer://${peer.id}${u.pathname}` };
} catch { return null; }
};
function refsOf(result) {
return result.chapters.map(ref => ({ id: ref.id, number: ref.number, volume: ref.volume,
book: { number: Number(ref.volume) || 1 }, title: ref.title, pages: ref.pageCount }));
}
async function discover(info) {
const result = await client.manga(store(), info.peerId, info.shareId);
return { title: result.title, chapters: refsOf(result), availability: { selectedChapters: result.chapters.length, coverage: 'shared-selection' } };
}
async function run(info, item, options) {
const stateStore = store(), peer = requirePeer(stateStore.load(), info.peerId);
if (!peer.paired || !peer.receive) throw new Error('Downloads from this peer are disabled');
const stop = () => { const reason = options.shouldStop?.(); if (reason) throw new ArchiveControlError(reason === true ? 'paused' : String(reason)); };
stop();
const remote = await client.manga(stateStore, info.peerId, info.shareId);
const catalog = { title: remote.title, chapters: refsOf(remote), availability: { selectedChapters: remote.chapters.length, coverage: 'shared-selection' } };
if (options.onCatalog) await options.onCatalog(catalog);
const sourceUrl = info.url, source = info.source;
const workRoot = path.join(options.libraryRoot, `.mcscraper-work-${info.id}`);
const stateFile = path.join(workRoot, 'job.json');
await fs.mkdir(workRoot, { recursive: true });
let job = { version: 1, publicationMode: 'incremental', source, seriesUrl: sourceUrl, completed: [], jobCompleted: 0,
status: 'running', started: new Date().toISOString(), updated: new Date().toISOString(), total: 0, current: null };
try { const prior = JSON.parse(await fs.readFile(stateFile, 'utf8')); if (prior.seriesUrl === sourceUrl) job = { ...prior, status: 'running' }; }
catch (e) { if (e.code !== 'ENOENT') throw e; }
const record = async () => { job.updated = new Date().toISOString(); await writeJsonAtomic(stateFile, job); };
const inventory = await scanLibrary(options.libraryRoot);
let manga = findManga(inventory, { source, seriesId: info.id, sourceUrl, libraryMangaId: item.libraryMangaId });
if (item.libraryMangaId && !manga) throw new Error(`Library manga target not found: ${item.libraryMangaId}`);
const selected = selectByScope(catalog.chapters.filter(ref => shouldDownload(manga, ref, source, item.existingPolicy)), item.scope);
job.total = selected.length; job.jobCompleted = 0; job.current = null; await record();
let publishedCount = 0;
const series = { title: remote.title, direction: ['rtl', 'ltr', 'ttb'].includes(remote.direction) ? remote.direction : 'rtl',
sources: [{ name: `Peer ${peer.alias}`, url: sourceUrl }],
metadata: { title: remote.title }, scraper: { ids: { [source]: info.id } } };
for (let index = 0; index < selected.length; index++) {
stop();
const ref = selected[index];
if (!shouldDownload(manga, ref, source, item.existingPolicy)) { job.jobCompleted++; await record(); continue; }
const chapter = await client.chapter(stateStore, info.peerId, info.shareId, ref.id);
if (String(chapter.number) !== String(ref.number) || String(chapter.volume) !== String(ref.volume)) throw new Error('Peer chapter identity changed');
const digest = crypto.createHash('sha256').update(JSON.stringify(chapter.pages.map(p => [p.id, p.sha256]))).digest('hex').slice(0, 20);
const stage = path.join(workRoot, 'pages', `${ref.id}-${digest}`);
await fs.mkdir(stage, { recursive: true });
job.current = { number: ref.number, sourceId: ref.id, position: index + 1, total: selected.length,
downloadedPages: 0, expectedPages: chapter.pages.length };
await record();
const pages = chapter.pages.map((page, i) => ({ ...page, path: path.join(stage, `${String(i + 1).padStart(4, '0')}.${page.mime.split('/')[1] === 'jpeg' ? 'jpg' : page.mime.split('/')[1]}`) }));
const concurrency = Math.min(4, Math.max(1, Number(requirePeer(stateStore.load(), info.peerId).download?.concurrent || 2)));
for (let offset = 0; offset < pages.length; offset += concurrency) {
stop();
const batch = pages.slice(offset, offset + concurrency);
await Promise.all(batch.map(page => client.downloadPage(stateStore, info.peerId, page, page.path)));
job.current.downloadedPages = Math.min(pages.length, offset + batch.length);
await record();
stop();
}
const publication = await publishManifest({ version: 1, series, chapters: [{
number: ref.number, title: ref.title, book: { number: Number(ref.volume) || 1 },
source: { name: `Peer ${peer.alias} ${peer.id.slice(1, 7)}`, url: sourceUrl },
pages: pages.map(page => ({ path: page.path })),
}] }, { libraryRoot: options.libraryRoot, ...(manga ? { mangaDir: manga.path } : {}) });
if (publication.results[0].status === 'published') {
publishedCount++;
if (!manga) manga = { id: path.basename(publication.mangaDir), path: publication.mangaDir, chapters: [], meta: {} };
manga.chapters.push({ book: String(Number(ref.volume) || 1), number: String(ref.number), sourceKey: source });
if (options.onPublished) await options.onPublished({ mangaDir: publication.mangaDir, chapter: ref.number });
}
job.jobCompleted++; job.lastChapter = ref.number; job.current = null; await record();
// Only a fully verified and published chapter loses its resumable page cache.
await fs.rm(stage, { recursive: true, force: true });
if (index < selected.length - 1) { stop(); await sleep(Math.max(500, Number(options.settings.chapterMinMs || 500))); }
}
job.status = 'complete'; job.current = null; await record();
return { finalPath: manga?.path || null, publishedCount, selectedCount: selected.length };
}
function adapterForPeer(value) {
const info = infoFor(value);
if (!info) return null;
const adapter = { id: info.source, label: `Peer ${info.peerId.slice(1, 7)}`,
async discover() { return discover(info); },
async run(item, options) { return run(info, item, options); } };
return { adapter, info };
}
module.exports = { adapterForPeer, infoFor, discover, run };
+146
View File
@@ -0,0 +1,146 @@
'use strict';
const https = require('node:https');
const fs = require('node:fs');
const fsp = fs.promises;
const crypto = require('node:crypto');
const { pipeline } = require('node:stream/promises');
const { Transform } = require('node:stream');
const { PeeringState, fp, requirePeer, grantStatus } = require('./state');
const MAX_JSON = 8 * 1024 * 1024;
const MAX_PAGE = 30 * 1024 * 1024;
const agents = new Map();
const downloadSlots = new Map();
function remoteError(code, message, retryAfter) {
const error = new Error(`Peer HTTP ${code}: ${String(message).slice(0, 300)}`);
error.status = code;
error.retryAfter = retryAfter;
return error;
}
function connection(store, id, needGrant = true, requireReceive = false) {
const state = store.load(), peer = requirePeer(state, id), identity = store.identity();
if (!identity) throw new Error('Generate local peering identity first');
if (needGrant && !peer.paired) throw new Error('Verify peer identity first');
if (requireReceive && !peer.receive) throw new Error('Downloads from this peer are disabled');
const token = store.outbound()[id];
if (needGrant && !token) throw new Error('Import the peer grant first');
const cacheKey = `${id}:${fp(peer.cert)}:${identity.fingerprint}`;
let agent = agents.get(cacheKey);
if (!agent) {
agent = new https.Agent({ keepAlive: true, maxSockets: Math.max(1, peer.download?.concurrent || 2), minVersion: 'TLSv1.3',
cert: identity.cert, key: identity.key, ca: peer.cert, rejectUnauthorized: true,
checkServerIdentity(_host, cert) {
if (!cert.raw || fp(cert.raw) !== peer.fingerprint) return new Error('Peer certificate fingerprint changed');
return undefined; // The out-of-band pin replaces a hostname CA hierarchy.
} });
agents.set(cacheKey, agent);
}
return { peer, agent, token };
}
function request(store, id, endpoint, options = {}) {
const { peer, agent, token } = connection(store, id, !options.pairing && !options.access, !!options.binary);
const base = new URL(peer.url), target = new URL(endpoint, base);
if (target.origin !== base.origin || !target.pathname.startsWith('/peer/v1/')) throw new Error('Invalid peer endpoint');
return new Promise((resolve, reject) => {
const headers = { accept: options.binary ? 'image/*' : 'application/json',
...(options.pairing ? { 'x-invitation-id': peer.invitationId || '', 'x-invitation-code': peer.invitationCode || '' }
: options.access ? {} : { authorization: `Bearer ${token}` }),
...options.headers };
const req = https.request(target, { method: 'GET', agent, headers, timeout: 30000 }, res => resolve(res));
req.on('timeout', () => req.destroy(new Error('Peer request timed out')));
req.on('error', reject); req.end();
});
}
async function readResponse(res, limit) {
const chunks = []; let size = 0;
for await (const chunk of res) { size += chunk.length; if (size > limit) throw new Error('Peer response exceeds limit'); chunks.push(chunk); }
return Buffer.concat(chunks);
}
async function jsonRequest(store, id, endpoint, options = {}) {
const res = await request(store, id, endpoint, options);
const data = await readResponse(res, MAX_JSON);
let value;
try { value = JSON.parse(data.toString('utf8')); } catch { throw new Error('Peer returned invalid JSON'); }
if (res.statusCode !== 200) throw remoteError(res.statusCode, value.error || 'Request failed', res.headers['retry-after']);
return value;
}
async function probe(store, id) {
const peer = requirePeer(store.load(), id);
if (Date.parse(peer.invitationExpiresAt) <= Date.now() && !peer.paired) throw new Error('Peer invitation expired');
const hello = await jsonRequest(store, id, '/peer/v1/hello', { pairing: !peer.paired });
if (hello.version !== 1 || hello.fingerprint !== peer.fingerprint) throw new Error('Peer hello identity mismatch');
const state = store.load(), paired = requirePeer(state, id); paired.paired = true;
delete paired.invitationId; delete paired.invitationCode; delete paired.invitationExpiresAt;
const file = store.stateFile(), temp = `${file}.${crypto.randomBytes(6).toString('hex')}.tmp`;
fs.writeFileSync(temp, JSON.stringify(state, null, 2) + '\n', { mode: 0o600 }); fs.renameSync(temp, file);
return { ok: true, id: hello.id };
}
async function access(store, id) {
const result = await jsonRequest(store, id, '/peer/v1/access', { access: true });
if (result.version !== 1 || typeof result.paired !== 'boolean' || !['none', 'active', 'paused', 'expired'].includes(result.grant?.status)
|| !Array.isArray(result.grant?.scopes)) throw new Error('Invalid peer access status');
return result;
}
async function catalog(store, id) {
const items = []; let cursor = null;
for (let page = 0; page < 100; page++) {
const result = await jsonRequest(store, id, `/peer/v1/catalog${cursor ? `?cursor=${encodeURIComponent(cursor)}` : ''}`);
if (!Array.isArray(result.items) || result.items.length > 50) throw new Error('Invalid peer catalog');
items.push(...result.items); cursor = result.nextCursor;
if (!cursor) return items;
}
throw new Error('Peer catalog exceeds page limit');
}
async function manga(store, id, shareId) {
if (!/^m[0-9a-f]{24}$/.test(shareId)) throw new Error('Invalid manga ID');
const chapters = []; let cursor = null, first;
for (let page = 0; page < 100; page++) {
const result = await jsonRequest(store, id, `/peer/v1/manga/${shareId}${cursor ? `?cursor=${encodeURIComponent(cursor)}` : ''}`);
if (result.id !== shareId || !Array.isArray(result.chapters) || result.chapters.length > 100) throw new Error('Invalid peer manga manifest');
first ||= result; chapters.push(...result.chapters); cursor = result.nextCursor;
if (!cursor) return { id: shareId, title: first.title, direction: first.direction, chapters };
}
throw new Error('Peer manga exceeds chapter limit');
}
async function chapter(store, id, shareId, chapterId) {
if (!/^m[0-9a-f]{24}$/.test(shareId) || !/^c[0-9a-f]{32}$/.test(chapterId)) throw new Error('Invalid chapter ID');
const result = await jsonRequest(store, id, `/peer/v1/manga/${shareId}/chapters/${chapterId}`);
if (result.id !== chapterId || !Array.isArray(result.pages) || !result.pages.length || result.pages.length > 500) throw new Error('Invalid peer chapter manifest');
for (const page of result.pages) if (!/^b[0-9a-f]{32}$/.test(page.id) || !Number.isSafeInteger(page.bytes) || page.bytes < 1 || page.bytes > MAX_PAGE
|| !/^[0-9a-f]{64}$/.test(page.sha256) || !/^image\/(?:jpeg|png|webp|gif|avif|bmp)$/.test(page.mime)) throw new Error('Invalid peer page');
return result;
}
async function downloadPage(store, id, page, file, shouldStop) {
const verify = async () => {
const digest = crypto.createHash('sha256');
for await (const chunk of fs.createReadStream(file)) digest.update(chunk);
return digest.digest('hex') === page.sha256;
};
let stat = await fsp.stat(file).catch(() => null);
if (stat?.size === page.bytes && await verify()) return { reused: true };
if (stat?.size === page.bytes) { await fsp.rm(file, { force: true }); stat = null; }
if (stat && stat.size > page.bytes) { await fsp.rm(file, { force: true }); stat = null; }
const start = stat?.size || 0;
if (shouldStop?.()) throw new Error('Peer transfer paused');
const res = await request(store, id, `/peer/v1/blobs/${page.id}`, { binary: true, headers: start ? { range: `bytes=${start}-`, ...(page.etag ? { 'if-match': page.etag } : {}) } : {} });
if (res.statusCode !== (start ? 206 : 200)) {
const body = await readResponse(res, 4096).catch(() => Buffer.alloc(0));
let message; try { message = JSON.parse(body.toString()).error; } catch { message = 'Transfer failed'; }
throw remoteError(res.statusCode, message, res.headers['retry-after']);
}
if (start && !String(res.headers['content-range'] || '').startsWith(`bytes ${start}-`)) throw new Error('Peer returned an invalid range');
if (Number(res.headers['content-length']) !== page.bytes - start) throw new Error('Peer returned an unexpected page size');
const rate = requirePeer(store.load(), id).download?.bytesPerSecond || 2 * 1024 * 1024;
const throttle = new Transform({ transform(chunk, _encoding, callback) {
const now = Date.now(), ready = Math.max(now, downloadSlots.get(id) || now) + chunk.length * 1000 / rate;
downloadSlots.set(id, ready);
setTimeout(() => callback(null, chunk), Math.max(0, ready - now));
} });
await pipeline(res, throttle, fs.createWriteStream(file, { flags: start ? 'a' : 'w', mode: 0o600 }));
stat = await fsp.stat(file);
if (stat.size !== page.bytes || !await verify()) { await fsp.rm(file, { force: true }); throw new Error('Peer page checksum mismatch'); }
return { reused: false };
}
module.exports = { connection, jsonRequest, probe, access, catalog, manga, chapter, downloadPage };
+236
View File
@@ -0,0 +1,236 @@
#!/usr/bin/env node
'use strict';
const https = require('node:https');
const fs = require('node:fs');
const fsp = fs.promises;
const path = require('node:path');
const crypto = require('node:crypto');
const { once } = require('node:events');
const { PeeringState, fp, hash, requirePeer, grantStatus } = require('./state');
const { parseKey, parseSpecialFolder } = require('../chapter-identity');
const DATA = path.resolve(process.env.PEER_DATA || path.join(process.env.DATA || 'data', 'peering'));
const LIBRARY = path.resolve(process.env.LIBRARY || 'library');
const HOST = process.env.PEER_HOST || '127.0.0.1';
const PORT = Number(process.env.PEER_PORT || 8093);
const peering = new PeeringState(DATA);
const identity = peering.identity();
if (!identity) throw new Error('Generate peering identity in the admin UI before starting the gateway');
const MAX_PAGE = 30 * 1024 * 1024;
const MIME = { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp', '.gif': 'image/gif', '.avif': 'image/avif', '.bmp': 'image/bmp' };
const usageFile = path.resolve(process.env.PEER_USAGE_FILE || path.join(DATA, 'gateway-usage.json'));
fs.mkdirSync(path.dirname(usageFile), { recursive: true, mode: 0o700 });
const usage = (() => { try { return JSON.parse(fs.readFileSync(usageFile, 'utf8')); } catch (e) { if (e.code === 'ENOENT') return { day: '', peers: {}, usedInvites: [] }; throw e; } })();
const active = new Map();
const uploadSlots = new Map();
const GLOBAL_STREAMS = Math.max(1, Math.min(32, Number(process.env.PEER_GLOBAL_STREAMS || 4)));
const indexCache = new Map();
const blobCache = new Map();
const sha = s => crypto.createHash('sha256').update(s).digest('hex');
const opaque = s => sha(s).slice(0, 32);
function saveUsage() { const tmp = `${usageFile}.tmp`; fs.writeFileSync(tmp, JSON.stringify(usage), { mode: 0o600 }); fs.renameSync(tmp, usageFile); }
function fail(code, message, retryAfter) { const e = new Error(message); e.code = code; e.retryAfter = retryAfter; throw e; }
function json(res, code, value, extra = {}) {
res.writeHead(code, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', ...extra });
res.end(JSON.stringify(value));
}
function peerOf(req) {
const cert = req.socket.getPeerCertificate(true);
if (!cert?.raw || Date.parse(cert.valid_to) <= Date.now() || Date.parse(cert.valid_from) > Date.now()) fail(401, 'Valid client certificate required');
const fingerprint = fp(cert.raw);
const state = peering.load();
const peer = state.peers.find(p => p.fingerprint === fingerprint);
if (!peer || fp(peer.cert) !== fingerprint) fail(401, 'Unrecognized client certificate');
return { state, peer };
}
function grant(req, peer, scope = 'catalog') {
const status = grantStatus(peer);
if (!peer.paired || status.status !== 'active') fail(403, `Peering grant ${status.status}`);
const match = /^Bearer ([A-Za-z0-9_-]{40,60})$/.exec(String(req.headers.authorization || ''));
if (!match) fail(401, 'Grant required');
const got = Buffer.from(hash(match[1]), 'hex'), want = Buffer.from(peer.grantHash, 'hex');
if (got.length !== want.length || !crypto.timingSafeEqual(got, want)) fail(401, 'Invalid grant');
if (!status.scopes.includes(scope)) fail(403, `Grant does not allow ${scope}`);
}
function quota(peer, bytes = 0, catalog = false) {
const now = new Date(), day = now.toISOString().slice(0, 10), minute = Math.floor(Date.now() / 60000);
if (usage.day !== day) { usage.day = day; usage.peers = {}; saveUsage(); }
const entry = usage.peers[peer.id] ||= { bytes: 0, minute, catalogs: 0 };
if (entry.minute !== minute) { entry.minute = minute; entry.catalogs = 0; }
if (catalog && entry.catalogs >= peer.limits.catalogPerMinute) fail(429, 'Catalog request limit reached', 60);
if (entry.bytes + bytes > peer.limits.bytesPerDay) fail(429, 'Daily upload limit reached', 3600);
if (catalog) entry.catalogs++;
if (bytes) entry.bytes += bytes; // Reserve before streaming; an interrupted stream never resets quota.
saveUsage();
}
function safeDir(parent, name) {
if (typeof name !== 'string' || !name || name.startsWith('.') || name.includes('/') || name.includes('\\') || name === '..') return null;
const file = path.join(parent, name);
if (!file.startsWith(parent + path.sep)) return null;
try { if (!fs.lstatSync(file).isDirectory()) return null; } catch { return null; }
return file;
}
async function dirs(root) {
try { return (await fsp.readdir(root, { withFileTypes: true })).filter(e => e.isDirectory() && !e.isSymbolicLink() && !e.name.startsWith('.')).map(e => e.name).sort(); }
catch { return []; }
}
async function jsonFile(file) { try { return JSON.parse(await fsp.readFile(file, 'utf8')); } catch { return {}; } }
async function mangaIndex(peer, share) {
const key = `${peer.id}:${share.id}`;
const old = indexCache.get(key);
if (old && Date.now() - old.at < 30000) return old;
const mangaDir = safeDir(LIBRARY, share.mangaId);
if (!mangaDir) fail(404, 'Manga not found');
const meta = await jsonFile(path.join(mangaDir, 'manga.json'));
const chapters = [], byId = new Map();
for (const bookName of await dirs(mangaDir)) {
const book = parseKey(bookName);
if (!book) continue;
const bookDir = safeDir(mangaDir, bookName);
for (const chapterName of await dirs(bookDir)) {
const parsed = parseSpecialFolder(chapterName) || parseKey(chapterName);
if (!parsed) continue;
const chapterDir = safeDir(bookDir, chapterName);
// A chapter without final metadata may still be a manually written partial.
if (!fs.existsSync(path.join(chapterDir, 'chapter.json'))) continue;
const entries = await fsp.readdir(chapterDir, { withFileTypes: true });
const pages = entries.filter(e => e.isFile() && MIME[path.extname(e.name).toLowerCase()]).map(e => e.name).sort();
if (!pages.length) continue;
const id = `c${opaque(`${share.id}/${bookName}/${chapterName}`)}`;
const chapterMeta = await jsonFile(path.join(chapterDir, 'chapter.json'));
const item = { id, number: parsed.key, volume: book.key, title: String(chapterMeta.title || parsed.title || '').slice(0, 200),
pageCount: pages.length, chapterDir, pages };
chapters.push(item); byId.set(id, item);
if (chapters.length > 10000) fail(413, 'Shared manga chapter limit exceeded');
}
}
const result = { at: Date.now(), mangaDir, meta, chapters, byId };
indexCache.set(key, result);
return result;
}
function permittedShare(peer, id) {
const share = (peer.shares || []).find(s => s.id === id);
if (!share) fail(404, 'Manga not found');
return share;
}
async function chapterManifest(peer, share, chapterId) {
const index = await mangaIndex(peer, share), chapter = index.byId.get(chapterId);
if (!chapter) fail(404, 'Chapter not found');
const pages = [];
for (const name of chapter.pages) {
const file = path.join(chapter.chapterDir, name), stat = await fsp.lstat(file);
if (!stat.isFile() || stat.size < 1 || stat.size > MAX_PAGE) fail(413, 'Page size outside transfer limits');
const digest = crypto.createHash('sha256');
for await (const chunk of fs.createReadStream(file)) digest.update(chunk);
const id = `b${opaque(`${share.id}/${chapter.id}/${name}`)}`;
const etag = `"${id}-${stat.size}-${Math.round(stat.mtimeMs)}"`;
pages.push({ id, bytes: stat.size, sha256: digest.digest('hex'), mime: MIME[path.extname(name).toLowerCase()], etag });
blobCache.set(`${peer.id}:${id}`, { file, chapterDir: chapter.chapterDir, shareId: share.id, chapterId,
size: stat.size, mtimeMs: stat.mtimeMs, at: Date.now() });
if (pages.length > 500) fail(413, 'Chapter page limit exceeded');
}
return { id: chapter.id, number: chapter.number, volume: chapter.volume, title: chapter.title, pages };
}
async function streamBlob(req, res, peer, id) {
const item = blobCache.get(`${peer.id}:${id}`);
if (!item || Date.now() - item.at > 30 * 60 * 1000) fail(404, 'Refresh chapter manifest');
permittedShare(peer, item.shareId);
if (!fs.existsSync(path.join(item.chapterDir, 'chapter.json'))) fail(404, 'Chapter no longer complete');
const stat = await fsp.lstat(item.file).catch(() => null);
if (!stat?.isFile() || stat.size !== item.size || stat.mtimeMs !== item.mtimeMs) fail(412, 'Page changed; refresh manifest');
const etag = `"${id}-${stat.size}-${Math.round(stat.mtimeMs)}"`;
if (req.headers['if-match'] && req.headers['if-match'] !== etag) fail(412, 'Page changed; refresh manifest');
const match = /^bytes=(\d+)-$/.exec(String(req.headers.range || ''));
if (req.headers.range && !match) fail(416, 'Unsupported range');
const start = match ? Number(match[1]) : 0;
if (!Number.isSafeInteger(start) || start >= stat.size) fail(416, 'Range outside page');
const count = stat.size - start;
if ((active.get(peer.id) || 0) >= peer.limits.concurrent) fail(429, 'Too many transfers', 2);
if ([...active.values()].reduce((a, b) => a + b, 0) >= GLOBAL_STREAMS) fail(429, 'Server transfer limit reached', 2);
quota(peer, count);
active.set(peer.id, (active.get(peer.id) || 0) + 1);
try {
res.writeHead(start ? 206 : 200, {
'content-type': MIME[path.extname(item.file).toLowerCase()], 'content-length': count,
'accept-ranges': 'bytes', etag,
...(start ? { 'content-range': `bytes ${start}-${stat.size - 1}/${stat.size}` } : {}),
'cache-control': 'no-store',
});
const stream = fs.createReadStream(item.file, { start, highWaterMark: 64 * 1024 });
for await (const chunk of stream) {
if (req.destroyed || res.destroyed) break;
// Reserve each chunk on a shared per-peer timeline, including simultaneous streams.
const now = Date.now(), ready = Math.max(now, uploadSlots.get(peer.id) || now) + chunk.length * 1000 / peer.limits.bytesPerSecond;
uploadSlots.set(peer.id, ready);
if (ready > now) await new Promise(resolve => setTimeout(resolve, ready - now));
if (!res.write(chunk)) await once(res, 'drain');
}
res.end();
} finally { active.set(peer.id, Math.max(0, (active.get(peer.id) || 1) - 1)); }
}
const server = https.createServer({ key: identity.key, cert: identity.cert, requestCert: true, rejectUnauthorized: false, minVersion: 'TLSv1.3' }, async (req, res) => {
try {
const url = new URL(req.url, 'https://peer');
if (req.method !== 'GET') fail(405, 'GET only');
if (url.pathname === '/health') return json(res, 200, { ok: true });
const { state, peer } = peerOf(req);
if (url.pathname === '/peer/v1/hello') {
if (!peer.paired) {
const invitation = state.invitations.find(i => i.id === req.headers['x-invitation-id']);
const code = String(req.headers['x-invitation-code'] || '');
if (!invitation || Date.parse(invitation.expiresAt) <= Date.now() || invitation.codeHash !== hash(code)
|| usage.usedInvites.includes(invitation.id)) fail(403, 'Valid unused invitation required');
usage.usedInvites.push(invitation.id); usage.usedInvites = usage.usedInvites.slice(-1000); saveUsage();
}
return json(res, 200, { version: 1, id: identity.id, fingerprint: identity.fingerprint });
}
if (url.pathname === '/peer/v1/access') {
if (!peer.paired) fail(403, 'Verify peer identity first');
return json(res, 200, { version: 1, paired: true, name: peering.load().name,
certificateExpiresAt: new crypto.X509Certificate(identity.cert).validTo,
grant: grantStatus(peer) });
}
const requiredScope = url.pathname.startsWith('/peer/v1/blobs/') ? 'download' : 'catalog';
grant(req, peer, requiredScope);
if (url.pathname === '/peer/v1/catalog') {
quota(peer, 0, true);
const cursor = Number(url.searchParams.get('cursor') || 0);
if (!Number.isSafeInteger(cursor) || cursor < 0) fail(400, 'Invalid cursor');
const shares = peer.shares || [], items = [];
for (const share of shares.slice(cursor, cursor + 50)) {
const mangaDir = safeDir(LIBRARY, share.mangaId);
if (!mangaDir) continue;
const meta = await jsonFile(path.join(mangaDir, 'manga.json'));
items.push({ id: share.id, title: String(meta.title || share.mangaId).slice(0, 200),
year: Number(meta.year || meta.startDate?.year) || null, status: String(meta.status || 'unknown').slice(0, 40) });
}
return json(res, 200, { version: 1, items, nextCursor: cursor + 50 < shares.length ? String(cursor + 50) : null });
}
const mangaMatch = /^\/peer\/v1\/manga\/(m[0-9a-f]{24})$/.exec(url.pathname);
if (mangaMatch) {
quota(peer, 0, true);
const share = permittedShare(peer, mangaMatch[1]), index = await mangaIndex(peer, share);
const cursor = Number(url.searchParams.get('cursor') || 0);
if (!Number.isSafeInteger(cursor) || cursor < 0) fail(400, 'Invalid cursor');
return json(res, 200, { id: share.id, title: String(index.meta.title || share.mangaId).slice(0, 200),
direction: ['rtl', 'ltr', 'ttb'].includes(index.meta.direction) ? index.meta.direction : 'rtl',
chapters: index.chapters.slice(cursor, cursor + 100).map(({ id, number, volume, title, pageCount }) => ({ id, number, volume, title, pageCount })),
nextCursor: cursor + 100 < index.chapters.length ? String(cursor + 100) : null });
}
const chapterMatch = /^\/peer\/v1\/manga\/(m[0-9a-f]{24})\/chapters\/(c[0-9a-f]{32})$/.exec(url.pathname);
if (chapterMatch) {
quota(peer, 0, true);
return json(res, 200, await chapterManifest(peer, permittedShare(peer, chapterMatch[1]), chapterMatch[2]));
}
const blobMatch = /^\/peer\/v1\/blobs\/(b[0-9a-f]{32})$/.exec(url.pathname);
if (blobMatch) return await streamBlob(req, res, peer, blobMatch[1]);
fail(404, 'Not found');
} catch (error) {
if (!res.headersSent) json(res, Number(error.code) || 500, { error: Number(error.code) ? error.message : 'Internal error' }, error.retryAfter ? { 'retry-after': String(error.retryAfter) } : {});
else res.destroy();
if (!Number(error.code)) console.error('[peer-gateway]', error.message);
}
});
server.listen(PORT, HOST, () => console.log(`[peer-gateway] listening on ${HOST}:${PORT}`));
+242
View File
@@ -0,0 +1,242 @@
'use strict';
const fs = require('node:fs');
const path = require('node:path');
const crypto = require('node:crypto');
const { execFileSync } = require('node:child_process');
const MAX_PEERS = 64;
const INVITE_MS = 48 * 60 * 60 * 1000;
const GRANT_MS = 90 * 24 * 60 * 60 * 1000;
const NAME_RE = /^[A-Za-z0-9]{1,25}$/;
const GRANT_SCOPES = new Set(['catalog', 'download']);
const fp = pem => new crypto.X509Certificate(pem).fingerprint256.replaceAll(':', '').toLowerCase();
const hash = value => crypto.createHash('sha256').update(value).digest('hex');
const peerId = pem => `p${fp(pem).slice(0, 24)}`;
const own = (root, name) => path.join(root, name);
function readJson(file, fallback) {
try { return JSON.parse(fs.readFileSync(file, 'utf8')); }
catch (e) { if (e.code === 'ENOENT') return fallback; throw e; }
}
function atomic(file, value) {
const temp = `${file}.${crypto.randomBytes(6).toString('hex')}.tmp`;
fs.writeFileSync(temp, JSON.stringify(value, null, 2) + '\n', { mode: 0o600 });
fs.renameSync(temp, file);
}
function requirePeer(state, id) {
const peer = state.peers.find(p => p.id === id);
if (!peer) throw new Error('Peer not found');
return peer;
}
function validUrl(value) {
const url = new URL(String(value));
if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash || url.pathname !== '/') throw new Error('Peer URL must be an HTTPS origin');
if (!url.hostname || url.hostname.length > 253) throw new Error('Invalid peer hostname');
return url.origin;
}
function publicPeer(peer, outbound) {
const grantValid = !!peer.grantHash && Date.parse(peer.grantExpiresAt) > Date.now();
return {
id: peer.id, alias: peer.alias, url: peer.url, fingerprint: peer.fingerprint,
paired: !!peer.paired, serve: grantValid && !peer.servingPaused, receive: !!peer.receive,
grantStatus: !peer.grantHash ? 'none' : !grantValid ? 'expired' : peer.servingPaused ? 'paused' : 'active',
grantScopes: peer.grantHash ? scopesFor(peer) : [], grantIssuedAt: peer.grantIssuedAt || null,
readerQueue: !!peer.readerQueue, shares: peer.shares || [],
limits: peer.limits, download: peer.download,
grantExpiresAt: peer.grantExpiresAt || null,
hasOutboundGrant: !!outbound?.[peer.id],
invitationExpiresAt: peer.invitationExpiresAt || null,
};
}
function serverName(value) {
const name = String(value ?? '').trim();
if (!NAME_RE.test(name)) throw new Error('Server name must contain 1–25 letters or numbers');
return name;
}
function normalizeScopes(value) {
const scopes = [...new Set((Array.isArray(value) ? value : []).map(String))].filter(scope => GRANT_SCOPES.has(scope));
if (scopes.includes('download') && !scopes.includes('catalog')) scopes.push('catalog');
return scopes;
}
function scopesFor(peer) {
return Array.isArray(peer.grantScopes) ? normalizeScopes(peer.grantScopes) : ['catalog', 'download'];
}
function grantStatus(peer) {
if (!peer.grantHash) return { status: 'none', scopes: [], issuedAt: null, expiresAt: null };
const scopes = scopesFor(peer), expiresAt = peer.grantExpiresAt || null;
const status = !expiresAt || Date.parse(expiresAt) <= Date.now() ? 'expired' : peer.servingPaused ? 'paused' : 'active';
return { status, scopes: status === 'expired' ? [] : scopes, issuedAt: peer.grantIssuedAt || null, expiresAt };
}
function normalizeLimits(input = {}, previous = {}) {
const defaults = { concurrent: 2, bytesPerSecond: 2 * 1024 * 1024, bytesPerDay: 10 * 1024 ** 3, catalogPerMinute: 30 };
const result = { ...defaults, ...previous };
for (const [key, min, max] of [['concurrent', 1, 8], ['bytesPerSecond', 65536, 100 * 1024 * 1024],
['bytesPerDay', 1024 * 1024, 10 * 1024 ** 4], ['catalogPerMinute', 1, 600]]) {
if (input[key] === undefined) continue;
const value = Number(input[key]);
if (!Number.isSafeInteger(value) || value < min || value > max) throw new Error(`Invalid ${key}`);
result[key] = value;
}
return result;
}
function normalizeDownload(input = {}, previous = {}) {
const result = { concurrent: 2, bytesPerSecond: 2 * 1024 * 1024, ...previous };
for (const [key, min, max] of [['concurrent', 1, 4], ['bytesPerSecond', 65536, 100 * 1024 * 1024]]) {
if (input[key] === undefined) continue;
const value = Number(input[key]);
if (!Number.isSafeInteger(value) || value < min || value > max) throw new Error(`Invalid ${key}`);
result[key] = value;
}
return result;
}
class PeeringState {
constructor(root) { this.root = path.resolve(root); }
stateFile() { return own(this.root, 'state.json'); }
outboundFile() { return own(this.root, 'outbound.json'); }
certFile() { return own(this.root, 'identity.crt'); }
keyFile() { return own(this.root, 'identity.key'); }
load() { return readJson(this.stateFile(), { version: 1, name: '', peers: [], invitations: [] }); }
outbound() { return readJson(this.outboundFile(), {}); }
identity() {
if (!fs.existsSync(this.certFile()) || !fs.existsSync(this.keyFile())) return null;
const cert = fs.readFileSync(this.certFile(), 'utf8');
return { cert, key: fs.readFileSync(this.keyFile(), 'utf8'), fingerprint: fp(cert), id: peerId(cert) };
}
setName(value) {
const state = this.load(); state.name = serverName(value); atomic(this.stateFile(), state);
return state.name;
}
generateIdentity(name) {
fs.mkdirSync(this.root, { recursive: true, mode: 0o700 });
if (this.identity()) throw new Error('Identity already exists; rotate it through a separate recovery procedure');
const state = this.load();
const cleanName = serverName(name || state.name);
if (state.name && state.name !== cleanName) throw new Error('Save this server name before generating its certificate');
const openssl = process.env.OPENSSL_BIN || 'openssl';
const key = this.keyFile(), cert = this.certFile();
try {
execFileSync(openssl, ['req', '-x509', '-newkey', 'ec', '-pkeyopt', 'ec_paramgen_curve:P-256', '-sha256', '-nodes',
'-keyout', key, '-out', cert, '-days', '365', '-subj', `/CN=${cleanName}`], { stdio: 'pipe', timeout: 15000 });
fs.chmodSync(key, 0o600); fs.chmodSync(cert, 0o600);
state.name = cleanName; atomic(this.stateFile(), state);
return { id: peerId(fs.readFileSync(cert, 'utf8')), fingerprint: fp(fs.readFileSync(cert, 'utf8')), name: cleanName };
} catch (error) {
for (const file of [key, cert]) { try { fs.rmSync(file, { force: true }); } catch {} }
throw new Error(`Could not generate peering identity: ${error.message}`);
}
}
createInvitation(url) {
const identity = this.identity();
if (!identity) throw new Error('Generate a server identity first');
const origin = validUrl(url);
const state = this.load();
const id = crypto.randomUUID(), code = crypto.randomBytes(32).toString('base64url');
const expiresAt = new Date(Date.now() + INVITE_MS).toISOString();
state.invitations = (state.invitations || []).filter(item => Date.parse(item.expiresAt) > Date.now()).slice(-50);
state.invitations.push({ id, codeHash: hash(code), expiresAt });
atomic(this.stateFile(), state);
return { version: 1, name: state.name, url: origin, cert: identity.cert,
fingerprint: identity.fingerprint, invitationId: id, code, expiresAt };
}
importInvitation(card) {
if (!card || card.version !== 1 || typeof card.cert !== 'string' || card.cert.length > 10000) throw new Error('Invalid invitation');
if (Date.parse(card.expiresAt) <= Date.now() || Date.parse(card.expiresAt) > Date.now() + INVITE_MS + 60000) throw new Error('Invitation expired or invalid');
if (!crypto.randomUUID || !/^[0-9a-f-]{36}$/i.test(String(card.invitationId))) throw new Error('Invalid invitation ID');
if (!/^[A-Za-z0-9_-]{40,60}$/.test(String(card.code))) throw new Error('Invalid invitation code');
const cert = new crypto.X509Certificate(card.cert);
const name = serverName(card.name);
if (Date.parse(cert.validTo) <= Date.now() || Date.parse(cert.validFrom) > Date.now()) throw new Error('Peer certificate is not valid now');
const fingerprint = fp(card.cert);
if (fingerprint !== String(card.fingerprint).replaceAll(':', '').toLowerCase()) throw new Error('Invitation fingerprint mismatch');
if (this.identity()?.fingerprint === fingerprint) throw new Error('Cannot pair with this server itself');
const state = this.load();
if (state.peers.length >= MAX_PEERS && !state.peers.some(p => p.fingerprint === fingerprint)) throw new Error('Peer limit reached');
let peer = state.peers.find(p => p.fingerprint === fingerprint);
if (!peer) {
peer = { id: peerId(card.cert), alias: name, url: validUrl(card.url),
cert: card.cert, fingerprint, paired: false, servingPaused: false, receive: false, readerQueue: false,
shares: [], limits: normalizeLimits(), download: normalizeDownload() };
state.peers.push(peer);
} else if (peer.url !== validUrl(card.url)) {
// A changed endpoint needs a new admin-reviewed invitation; never silently redirect traffic.
peer.url = validUrl(card.url); peer.paired = false; peer.servingPaused = false; peer.receive = false;
delete peer.grantHash; delete peer.grantScopes; delete peer.grantExpiresAt; delete peer.grantIssuedAt;
}
peer.invitationId = card.invitationId; peer.invitationCode = card.code; peer.invitationExpiresAt = card.expiresAt;
atomic(this.stateFile(), state);
return publicPeer(peer, this.outbound());
}
updatePeer(id, patch) {
const state = this.load(), peer = requirePeer(state, id);
if (patch.alias !== undefined) peer.alias = serverName(patch.alias);
if (patch.limits !== undefined) peer.limits = normalizeLimits(patch.limits, peer.limits);
if (patch.download !== undefined) peer.download = normalizeDownload(patch.download, peer.download);
if (patch.readerQueue !== undefined) peer.readerQueue = !!patch.readerQueue;
if (patch.receive !== undefined) peer.receive = !!patch.receive && !!peer.paired;
if (patch.servingPaused !== undefined) peer.servingPaused = !!patch.servingPaused;
// Accept the legacy field without revoking credentials: false now means pause.
if (patch.serve !== undefined) {
if (patch.serve && !peer.paired) throw new Error('Verify pairing before enabling serving');
peer.servingPaused = !patch.serve;
}
if (patch.grantScopes !== undefined) {
if (!peer.grantHash) throw new Error('Issue a grant before changing its permissions');
const scopes = normalizeScopes(patch.grantScopes);
if (!scopes.length) throw new Error('Select at least one grant permission or revoke the grant');
peer.grantScopes = scopes;
}
atomic(this.stateFile(), state);
return publicPeer(peer, this.outbound());
}
setShare(id, mangaId, enabled) {
const state = this.load(), peer = requirePeer(state, id);
if (typeof mangaId !== 'string' || !mangaId || mangaId.startsWith('.') || mangaId.includes('/') || mangaId.includes('\\')) throw new Error('Invalid manga ID');
peer.shares ||= [];
const existing = peer.shares.find(s => s.mangaId === mangaId);
if (enabled && !existing) peer.shares.push({ mangaId, id: `m${crypto.randomBytes(12).toString('hex')}` });
if (!enabled) peer.shares = peer.shares.filter(s => s.mangaId !== mangaId);
atomic(this.stateFile(), state);
return publicPeer(peer, this.outbound());
}
issueGrant(id, scopes = ['catalog', 'download']) {
const state = this.load(), peer = requirePeer(state, id);
if (!peer.paired) throw new Error('Verify pairing before issuing a grant');
const selected = normalizeScopes(scopes);
if (!selected.length) throw new Error('Select at least one grant permission');
const token = crypto.randomBytes(32).toString('base64url');
peer.grantHash = hash(token);
peer.grantScopes = selected; peer.grantIssuedAt = new Date().toISOString(); peer.servingPaused = false;
peer.grantExpiresAt = new Date(Math.min(Date.now() + GRANT_MS, Date.parse(new crypto.X509Certificate(peer.cert).validTo), Date.parse(new crypto.X509Certificate(this.identity().cert).validTo))).toISOString();
atomic(this.stateFile(), state);
return { peerId: peer.id, token, expiresAt: peer.grantExpiresAt };
}
revokeGrant(id) {
const state = this.load(), peer = requirePeer(state, id);
for (const key of ['grantHash', 'grantScopes', 'grantIssuedAt', 'grantExpiresAt']) delete peer[key];
peer.servingPaused = false; atomic(this.stateFile(), state);
return publicPeer(peer, this.outbound());
}
acceptGrant(id, token) {
const state = this.load(), peer = requirePeer(state, id);
if (!/^[A-Za-z0-9_-]{40,60}$/.test(String(token))) throw new Error('Invalid grant token');
const outbound = this.outbound(); outbound[id] = String(token); atomic(this.outboundFile(), outbound);
return publicPeer(peer, outbound);
}
removePeer(id) {
const state = this.load(); requirePeer(state, id);
state.peers = state.peers.filter(p => p.id !== id); atomic(this.stateFile(), state);
const outbound = this.outbound(); delete outbound[id]; atomic(this.outboundFile(), outbound);
}
publicState() {
const state = this.load(), identity = this.identity(), outbound = this.outbound();
return { identity: identity ? { id: identity.id, fingerprint: identity.fingerprint, name: state.name,
expiresAt: new crypto.X509Certificate(identity.cert).validTo } : null,
peers: state.peers.map(p => publicPeer(p, outbound)),
invitations: (state.invitations || []).filter(i => Date.parse(i.expiresAt) > Date.now()).map(i => ({ expiresAt: i.expiresAt })),
certificateExpiresAt: identity ? new crypto.X509Certificate(identity.cert).validTo : null };
}
}
module.exports = { PeeringState, fp, hash, peerId, validUrl, requirePeer, normalizeLimits, normalizeDownload,
serverName, normalizeScopes, scopesFor, grantStatus };
+137
View File
@@ -0,0 +1,137 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const fsp = fs.promises;
const os = require('node:os');
const path = require('node:path');
const net = require('node:net');
const https = require('node:https');
const { spawn } = require('node:child_process');
const { PeeringState } = require('../state');
const client = require('../client');
const ROOT = path.resolve(__dirname, '../..');
const OPENSSL = process.env.OPENSSL_BIN || (process.platform === 'win32' ? 'C:\\Program Files\\Git\\usr\\bin\\openssl.exe' : 'openssl');
const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9Zr5sAAAAASUVORK5CYII=', 'base64');
async function port() {
const server = net.createServer(); await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const value = server.address().port; await new Promise(resolve => server.close(resolve)); return value;
}
async function health(value) {
return new Promise((resolve, reject) => {
https.get({ hostname: '127.0.0.1', port: value, path: '/health', rejectUnauthorized: false }, res => {
res.resume(); res.on('end', () => resolve(res.statusCode));
}).on('error', reject);
});
}
async function gateway(data, library, value, usage) {
const child = spawn(process.execPath, ['peering/gateway.js'], { cwd: ROOT, env: { ...process.env,
PEER_DATA: data, LIBRARY: library, PEER_PORT: String(value), PEER_HOST: '127.0.0.1', PEER_USAGE_FILE: usage }, stdio: ['ignore', 'pipe', 'pipe'] });
let error = ''; child.stderr.on('data', chunk => { error += chunk.toString(); });
for (let i = 0; i < 50; i++) {
if (child.exitCode !== null) throw new Error(`Gateway exited: ${error}`);
try { if (await health(value) === 200) return child; } catch {}
await new Promise(resolve => setTimeout(resolve, 100));
}
child.kill(); throw new Error(`Gateway failed to start: ${error}`);
}
async function stop(child) {
if (child.exitCode !== null) return;
child.kill('SIGTERM'); await new Promise(resolve => { child.once('exit', resolve); setTimeout(resolve, 3000).unref(); });
}
test('mTLS pairing, opt-in catalog, peer queue publication, and revocation', { timeout: 30000 }, async t => {
const root = await fsp.mkdtemp(path.join(os.tmpdir(), 'mangacircus-peering-'));
const dataA = path.join(root, 'a-data'), dataB = path.join(root, 'b-data');
const libA = path.join(root, 'a-library'), libB = path.join(root, 'b-library');
await Promise.all([dataA, dataB, libA, libB].map(dir => fsp.mkdir(dir, { recursive: true })));
t.after(async () => { await fsp.rm(root, { recursive: true, force: true }); });
const a = new PeeringState(dataA), b = new PeeringState(dataB);
process.env.OPENSSL_BIN = OPENSSL;
a.generateIdentity('ServerA'); b.generateIdentity('ServerB');
const beforeInvalidInvite = a.load().invitations.length;
assert.throws(() => a.createInvitation('https://localhost:8080/path'), /HTTPS origin/);
assert.throws(() => a.createInvitation('localhost:8080'), /HTTPS origin/);
assert.equal(a.load().invitations.length, beforeInvalidInvite, 'Invalid URLs must not record unusable invitations');
const portA = await port(), portB = await port();
const inviteA = a.createInvitation(`https://127.0.0.1:${portA}/`);
const inviteB = b.createInvitation(`https://127.0.0.1:${portB}/`);
const inA = a.importInvitation(inviteB), inB = b.importInvitation(inviteA);
assert.equal(inA.paired, false);
assert.equal(a.importInvitation(inviteB).id, inA.id, 'Importing one certificate must not duplicate a peer');
assert.throws(() => a.importInvitation({ ...inviteB, fingerprint: '0'.repeat(64) }), /fingerprint mismatch/);
assert.throws(() => a.importInvitation({ ...inviteB, expiresAt: new Date(Date.now() - 1000).toISOString() }), /expired/);
await assert.rejects(() => client.catalog(b, inB.id), /Verify peer identity first/);
const mangaDir = path.join(libA, 'Sample Manga');
const chapterDir = path.join(mangaDir, 'v01', 'c0001 [Local]');
await fsp.mkdir(chapterDir, { recursive: true });
await fsp.writeFile(path.join(mangaDir, 'manga.json'), JSON.stringify({ title: 'Sample Manga', direction: 'ltr' }));
await fsp.writeFile(path.join(chapterDir, 'chapter.json'), JSON.stringify({ title: 'The first chapter', source: { name: 'Local' } }));
await fsp.writeFile(path.join(chapterDir, '0001.png'), PNG);
const ga = await gateway(dataA, libA, portA, path.join(root, 'usage-a.json'));
const gb = await gateway(dataB, libB, portB, path.join(root, 'usage-b.json'));
t.after(async () => { await stop(ga); await stop(gb); });
await client.probe(b, inB.id);
await client.probe(a, inA.id);
assert.equal(a.publicState().peers[0].paired, true);
a.setShare(inA.id, 'Sample Manga', true);
const c = new PeeringState(path.join(root, 'c-data'));
c.generateIdentity('ServerC');
const inC = a.importInvitation(c.createInvitation('https://third-peer.example:8093/'));
assert.equal(a.publicState().peers.length, 2);
assert.deepEqual(a.publicState().peers.find(p => p.id === inC.id).shares, []);
assert.equal(a.publicState().peers.find(p => p.id === inA.id).shares.length, 1);
a.updatePeer(inA.id, { serve: true });
const grant = a.issueGrant(inA.id, ['catalog']);
b.acceptGrant(inB.id, grant.token);
const listing = await client.catalog(b, inB.id);
assert.equal(listing.length, 1);
assert.equal(listing[0].title, 'Sample Manga');
assert.equal(JSON.stringify(listing).includes('Sample Manga\\v01'), false);
const remote = await client.manga(b, inB.id, listing[0].id);
assert.equal(remote.chapters.length, 1);
const remoteAccess = await client.access(b, inB.id);
assert.equal(remoteAccess.grant.status, 'active');
assert.deepEqual(remoteAccess.grant.scopes, ['catalog']);
assert.throws(() => client.connection(b, inB.id, true, true), /Downloads from this peer are disabled/);
b.updatePeer(inB.id, { receive: true });
const manifest = await client.chapter(b, inB.id, listing[0].id, remote.chapters[0].id);
assert.equal(manifest.pages.length, 1);
assert.equal(manifest.pages[0].sha256, require('node:crypto').createHash('sha256').update(PNG).digest('hex'));
await assert.rejects(() => client.downloadPage(b, inB.id, manifest.pages[0], path.join(root, 'denied.png')),
/Peer HTTP 403: Grant does not allow download/);
a.updatePeer(inA.id, { grantScopes: ['catalog', 'download'] });
assert.deepEqual((await client.access(b, inB.id)).grant.scopes, ['catalog', 'download'], 'Permission edits apply without exchanging a new token');
await assert.rejects(() => client.jsonRequest(b, inB.id, '/peer/v1/manga/m000000000000000000000000'), /Peer HTTP 404/);
await assert.rejects(() => client.jsonRequest(b, inB.id, '/peer/v1/catalog', { headers: { authorization: 'Bearer ' + 'x'.repeat(43) } }), /Peer HTTP 401/);
const noCert = await new Promise((resolve, reject) => https.get({ hostname: '127.0.0.1', port: portA,
path: '/peer/v1/catalog', rejectUnauthorized: false }, res => { res.resume(); res.on('end', () => resolve(res.statusCode)); }).on('error', reject));
assert.equal(noCert, 401);
const page = manifest.pages[0], { agent } = client.connection(b, inB.id);
const partial = await new Promise((resolve, reject) => https.get(`https://127.0.0.1:${portA}/peer/v1/blobs/${page.id}`,
{ agent, headers: { authorization: `Bearer ${b.outbound()[inB.id]}`, range: 'bytes=4-', 'if-match': page.etag } }, res => {
const chunks = []; res.on('data', chunk => chunks.push(chunk)); res.on('end', () => resolve({ status: res.statusCode, body: Buffer.concat(chunks), range: res.headers['content-range'] }));
}).on('error', reject));
assert.equal(partial.status, 206);
assert.equal(partial.range, `bytes 4-${PNG.length - 1}/${PNG.length}`);
assert.deepEqual(partial.body, PNG.subarray(4));
const { infoFor, run } = require('../adapter');
process.env.PEER_DATA = dataB;
const uri = `peer://${inB.id}/${listing[0].id}`;
const info = infoFor(uri);
const result = await run(info, { scope: { mode: 'missing' }, existingPolicy: 'skip-any' },
{ libraryRoot: libB, settings: { chapterMinMs: 500 }, shouldStop: () => false });
assert.equal(result.publishedCount, 1);
assert.equal(fs.existsSync(path.join(libB, 'Sample Manga', 'v01', 'c0001 The first chapter [Peer ServerA ' + inB.id.slice(1, 7) + ']', '0001.png')), true);
const second = await run(info, { scope: { mode: 'missing' }, existingPolicy: 'skip-any' },
{ libraryRoot: libB, settings: { chapterMinMs: 500 }, shouldStop: () => false });
assert.equal(second.publishedCount, 0);
a.updatePeer(inA.id, { servingPaused: true });
assert.equal((await client.access(b, inB.id)).grant.status, 'paused');
await assert.rejects(() => client.catalog(b, inB.id), /Peer HTTP 403/);
assert.equal(a.publicState().peers.find(p => p.id === inA.id).grantStatus, 'paused', 'Pausing must retain the grant');
a.revokeGrant(inA.id);
assert.equal(a.publicState().peers.find(p => p.id === inA.id).grantStatus, 'none');
await assert.rejects(() => client.chapter(b, inB.id, listing[0].id, remote.chapters[0].id), /Peer HTTP 403/);
});
+72
View File
@@ -0,0 +1,72 @@
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs'), os = require('node:os'), path = require('node:path');
const { PeeringState } = require('../peering/state');
module.exports = async function testPeerUI(url, cookie) {
const { chromium } = require('playwright');
const browser = await chromium.launch({ channel: process.env.QA_BROWSER_CHANNEL || 'msedge', headless: true });
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
const [name, ...value] = cookie.split('=');
await context.addCookies([{ name, value: value.join('='), url }]);
const page = await context.newPage(), errors = [];
page.on('pageerror', error => errors.push(error.message));
try {
await page.goto(url + '/peers.html');
await page.getByRole('heading', { name: 'Peer catalogs', exact: true }).waitFor();
let state = await page.evaluate(() => fetch('/api/admin/peering').then(response => response.json()));
if (!state.identity) {
await page.getByRole('button', { name: 'Generate local certificate' }).click();
await page.getByText('Identity generated').waitFor();
state = await page.evaluate(() => fetch('/api/admin/peering').then(response => response.json()));
}
assert.match(state.identity.fingerprint, /^[0-9a-f]{64}$/);
await page.getByLabel('Reachable peer-gateway URL').fill('http://localhost:8080');
await page.getByRole('button', { name: 'Create 48-hour invitation' }).click();
assert.match(await page.getByLabel('Reachable peer-gateway URL').evaluate(el => el.validationMessage), /HTTPS peer-gateway origin/);
await page.getByLabel('Reachable peer-gateway URL').fill('https://peer-test.example:8093');
await page.getByRole('button', { name: 'Create 48-hour invitation' }).click();
await page.waitForFunction(() => document.querySelector('textarea[aria-label="Invitation to exchange"]')?.value.includes('fingerprint'));
const invitation = JSON.parse(await page.getByLabel('Invitation to exchange').inputValue());
assert.equal(invitation.url, 'https://peer-test.example:8093');
assert.equal(invitation.fingerprint, state.identity.fingerprint);
await page.getByLabel('Other server invitation').fill('{bad json');
await page.getByRole('button', { name: 'Import invitation' }).click();
await page.getByText('Invalid invitation JSON').waitFor();
const peerDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-peer-ui-'));
try {
if (process.platform === 'win32' && !process.env.OPENSSL_BIN) process.env.OPENSSL_BIN = 'C:\\Program Files\\Git\\usr\\bin\\openssl.exe';
const peer = new PeeringState(peerDir);
peer.generateIdentity('UIFixturePeer');
const fixtureInvitation = peer.createInvitation('https://fixture-peer.example:8093');
await page.getByLabel('Other server invitation').fill(JSON.stringify(fixtureInvitation));
await page.getByRole('button', { name: 'Import invitation' }).click();
await page.getByRole('heading', { name: 'UI fixture peer' }).waitFor();
const card = page.locator('.peer-card').filter({ has: page.getByRole('heading', { name: 'UI fixture peer' }) });
await card.getByText('Transfer limits').click();
await card.getByText('Approved manga (0)').click();
await card.getByText('Access grant').click();
await card.getByLabel('Moon Garden').check();
await card.getByText('Approved manga (1)').waitFor();
assert.equal(await card.locator('details[open]').count(), 3, 'Saving a share must preserve all expanded sections');
} finally { fs.rmSync(peerDir, { recursive: true, force: true }); }
assert.equal(errors.length, 0, `Browser errors: ${errors.join('; ')}`);
await page.setViewportSize({ width: 390, height: 844 });
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), 'Peering page overflows on mobile');
// An ordinary reader may browse, but cannot operate certificate controls.
const created = await page.evaluate(async () => {
const response = await fetch('/api/admin/user', { method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ name: 'peer-reader-qa', password: 'disposable-reader-test', admin: false }) });
return response.status;
});
assert.equal(created, 200);
await page.evaluate(() => fetch('/api/logout', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }));
await page.evaluate(() => fetch('/api/login', { method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ name: 'peer-reader-qa', password: 'disposable-reader-test' }) }));
await page.reload();
await page.getByRole('heading', { name: 'Peer catalogs', exact: true }).waitFor();
assert.equal(await page.getByRole('heading', { name: 'Server identity & invitations' }).count(), 0);
assert.equal((await page.evaluate(() => fetch('/api/admin/peering').then(response => response.status))), 403);
assert.equal(errors.length, 0, `Browser errors: ${errors.join('; ')}`);
} finally { await browser.close(); }
};