Files
MangaCircus/peering/state.js
T

243 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 };