Add scoped peer access and reversible serving controls
This commit is contained in:
1 parent
6c994113c6
commit
492477f86a
7 files changed
+1081
No files matched your search
@@ -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 };
|
||||
Reference in new issue
Block a user