199 lines
11 KiB
TypeScript
199 lines
11 KiB
TypeScript
import { request as httpRequest } from 'node:http';
|
|
import { request as httpsRequest } from 'node:https';
|
|
import dns from 'node:dns/promises';
|
|
import { BlockList, isIP } from 'node:net';
|
|
import { z } from 'zod';
|
|
import { NETWORK, type TestnetBlock, type TestnetTransaction } from '../../shared/testnet.ts';
|
|
import { CHAIN_ID, blockSchema, transactionId, transactionSchema } from './consensus.ts';
|
|
import type { Ledger } from './ledger.ts';
|
|
|
|
const MAX_BODY = 2 * 1024 * 1024;
|
|
const excluded = new BlockList();
|
|
for (const [ip, prefix] of [ ['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10],
|
|
['127.0.0.0', 8], ['169.254.0.0', 16], ['172.16.0.0', 12], ['192.0.0.0', 24],
|
|
['192.0.2.0', 24], ['192.168.0.0', 16], ['192.88.99.0', 24], ['198.18.0.0', 15],
|
|
['198.51.100.0', 24], ['203.0.113.0', 24], ['224.0.0.0', 3] ] as const) excluded.addSubnet(ip, prefix, 'ipv4');
|
|
const globalV6 = new BlockList();
|
|
globalV6.addSubnet('2000::', 3, 'ipv6');
|
|
for (const [ip, prefix] of [['2001::', 23], ['2001:db8::', 32], ['2002::', 16],
|
|
['3fff::', 20]] as const) excluded.addSubnet(ip, prefix, 'ipv6');
|
|
function publicAddress(ip: string): boolean {
|
|
const family = isIP(ip);
|
|
if (family === 4) return !excluded.check(ip, 'ipv4');
|
|
// Only global unicast: also rejects mapped IPv4, local, unspecified and multicast IPv6.
|
|
return family === 6 && globalV6.check(ip, 'ipv6') && !excluded.check(ip, 'ipv6');
|
|
}
|
|
const remoteStatus = z.object({ network: z.literal(NETWORK), chainId: z.literal(CHAIN_ID),
|
|
size: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER), tip: z.string().regex(/^[a-f0-9]{64}$/).optional() }).strict();
|
|
const pageSchema = z.object({ blocks: z.array(blockSchema).max(32),
|
|
next: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER).nullable() }).strict();
|
|
const poolSchema = z.object({ transactions: z.array(transactionSchema).max(256) }).strict();
|
|
type Peer = { url: string; connected: boolean; error?: string; offset: number; busy: boolean };
|
|
|
|
/** Outbound-only fixed allowlist. HTTP routes are deliberately left to the caller.
|
|
* allowPrivate is an explicit operator permission for local/Docker networks, not peer input.
|
|
*/
|
|
export class PeerNetwork {
|
|
private readonly peers: Peer[];
|
|
private readonly ledger: Ledger;
|
|
private readonly allowPrivate: boolean;
|
|
private readonly intervalMs: number;
|
|
private timer: ReturnType<typeof setInterval> | undefined;
|
|
private closed = false;
|
|
private readonly active = new Set<AbortController>();
|
|
|
|
constructor(options: { ledger: Ledger; urls: string[]; allowPrivate: boolean; intervalMs?: number }) {
|
|
this.ledger = options.ledger;
|
|
this.allowPrivate = options.allowPrivate === true;
|
|
this.intervalMs = options.intervalMs ?? 5000;
|
|
if (!Number.isSafeInteger(this.intervalMs) || this.intervalMs < 1000 || this.intervalMs > 3_600_000) {
|
|
throw new Error('Peer interval must be 1000..3600000 ms');
|
|
}
|
|
if (options.urls.length > 16) throw new Error('At most 16 configured peers');
|
|
this.peers = [...new Set(options.urls.map(raw => {
|
|
if (raw.length > 2048) throw new Error('Peer URL too long');
|
|
// Reject forbidden syntax before URL normalization can erase paths or empty delimiters.
|
|
if (!/^https?:\/\/[^/?#\\@\s]+\/?$/i.test(raw)) throw new Error('Peer URL must contain only an origin');
|
|
const url = new URL(raw);
|
|
if (url.username || url.password || url.pathname !== '/' || url.search || url.hash ||
|
|
(url.protocol !== 'https:' && !(this.allowPrivate && url.protocol === 'http:'))) {
|
|
throw new Error('Peer URL must be an HTTPS origin without credentials/path/query/fragment');
|
|
}
|
|
const host = url.hostname.replace(/^\[|\]$/g, '');
|
|
if (!this.allowPrivate && (host.toLowerCase().replace(/\.$/, '') === 'localhost' ||
|
|
host.toLowerCase().replace(/\.$/, '').endsWith('.localhost') ||
|
|
(isIP(host) && !publicAddress(host)))) throw new Error('Private peer requires allowPrivate');
|
|
return url.origin;
|
|
}))].map(url => ({ url, connected: false, offset: 0, busy: false }));
|
|
}
|
|
|
|
start(): void {
|
|
if (this.closed || this.timer) return;
|
|
const pass = () => { for (const peer of this.peers) void this.run(peer, () => this.sync(peer)); };
|
|
pass();
|
|
this.timer = setInterval(pass, this.intervalMs);
|
|
this.timer.unref();
|
|
}
|
|
close(): void {
|
|
this.closed = true;
|
|
if (this.timer) clearInterval(this.timer);
|
|
this.timer = undefined;
|
|
for (const controller of this.active) controller.abort();
|
|
for (const peer of this.peers) peer.connected = false;
|
|
this.updateCount();
|
|
}
|
|
status(): { url: string; connected: boolean; error?: string }[] {
|
|
return this.peers.map(({ url, connected, error }) => ({ url, connected, ...(error ? { error } : {}) }));
|
|
}
|
|
async broadcastBlock(block: TestnetBlock): Promise<void> {
|
|
const known = this.ledger.block(blockSchema.parse(block).hash);
|
|
if (!known) throw new Error('Cannot broadcast an unaccepted block');
|
|
await this.broadcast('/p2p/block', known);
|
|
}
|
|
async broadcastTransfer(tx: TestnetTransaction): Promise<void> {
|
|
return this.broadcastTransaction(tx);
|
|
}
|
|
async broadcastTransaction(tx: TestnetTransaction): Promise<void> {
|
|
const id = transactionId(transactionSchema.parse(tx));
|
|
const snapshot = this.ledger.snapshot();
|
|
const known = [...snapshot.pendingTransfers, ...snapshot.transfers].find(t => transactionId(t) === id);
|
|
if (!known) throw new Error('Cannot broadcast an unaccepted transfer');
|
|
await this.broadcast('/p2p/transaction', known);
|
|
}
|
|
private async broadcast(path: string, body: unknown): Promise<void> {
|
|
// Drop gossip to busy/disconnected peers instead of building an unbounded retry queue.
|
|
await Promise.all(this.peers.filter(p => p.connected).map(p => this.run(p, async () => {
|
|
await this.request(p, path, body);
|
|
})));
|
|
}
|
|
private async run(peer: Peer, operation: () => Promise<void>): Promise<void> {
|
|
if (this.closed || peer.busy) return;
|
|
peer.busy = true;
|
|
try {
|
|
await operation();
|
|
if (!this.closed) { peer.connected = true; delete peer.error; }
|
|
} catch (error) {
|
|
peer.connected = false;
|
|
peer.error = (error instanceof Error ? error.message : 'Peer request failed').replace(/[\r\n\x00-\x1f]/g, ' ').slice(0, 200);
|
|
} finally { peer.busy = false; this.updateCount(); }
|
|
}
|
|
private updateCount(): void { this.ledger.peers = this.peers.filter(p => p.connected).length; }
|
|
private async sync(peer: Peer): Promise<void> {
|
|
const status = remoteStatus.parse(await this.request(peer, '/p2p/status'));
|
|
if (peer.offset > status.size) peer.offset = 0;
|
|
if (peer.offset < status.size) {
|
|
const page = pageSchema.parse(await this.request(peer, `/p2p/blocks?offset=${peer.offset}`));
|
|
const end = peer.offset + page.blocks.length;
|
|
if (!page.blocks.length || !Number.isSafeInteger(end) ||
|
|
(page.next !== null && page.next !== end) ||
|
|
(page.next === null && end < status.size)) throw new Error('Invalid peer pagination');
|
|
for (const block of page.blocks) {
|
|
if (this.closed) throw new Error('Peer network closed');
|
|
if (!this.ledger.block(block.hash)) await this.ledger.submit(block);
|
|
}
|
|
peer.offset = end; // At most 32 blocks per peer/pass, never trust a remote cursor.
|
|
}
|
|
const pool = poolSchema.parse(await this.request(peer, '/p2p/mempool'));
|
|
for (const tx of pool.transactions) {
|
|
if (this.closed) throw new Error('Peer network closed');
|
|
// Valid remote transactions may be immature/conflicting on our current tip.
|
|
try { await this.ledger.submitTransaction(tx); } catch { /* Independently reject; do not relay. */ }
|
|
}
|
|
}
|
|
|
|
private async request(peer: Peer, path: string, body?: unknown): Promise<unknown> {
|
|
if (this.closed) throw new Error('Peer network closed');
|
|
const url = new URL(path, peer.url);
|
|
const controller = new AbortController();
|
|
this.active.add(controller);
|
|
const timeout = setTimeout(() => controller.abort(), 10_000);
|
|
const encoded = body === undefined ? undefined : Buffer.from(JSON.stringify(body));
|
|
try {
|
|
if (encoded && encoded.length > MAX_BODY) throw new Error('Peer request exceeds 2 MiB');
|
|
return await new Promise<unknown>((resolve, reject) => {
|
|
const request = (url.protocol === 'https:' ? httpsRequest : httpRequest)(url, {
|
|
method: encoded ? 'POST' : 'GET', signal: controller.signal, agent: false,
|
|
// Supported by net.connect; absent from HTTP's narrower RequestOptions type.
|
|
...{ autoSelectFamily: false }, maxHeaderSize: 16 * 1024,
|
|
headers: { accept: 'application/json', ...(encoded ? {
|
|
'content-type': 'application/json', 'content-length': encoded.length } : {}) },
|
|
// Validate every DNS answer and pin the connection to that exact lookup result.
|
|
// A separate preflight lookup followed by fetch would allow DNS rebinding.
|
|
lookup: (host, _options, callback) => {
|
|
void dns.lookup(host, { all: true, verbatim: true }).then(addresses => {
|
|
if (!addresses.length || (!this.allowPrivate && addresses.some(a => !publicAddress(a.address)))) {
|
|
callback(new Error('Peer DNS resolves to a non-public address'), '', 4);
|
|
} else callback(null, addresses[0].address, addresses[0].family);
|
|
}, () => callback(new Error('Peer DNS lookup failed'), '', 4));
|
|
},
|
|
}, response => {
|
|
// Node's HTTP client never follows redirects. Reject them, including same-origin.
|
|
if (!response.statusCode || response.statusCode < 200 || response.statusCode >= 300) {
|
|
response.destroy(); reject(new Error(`Peer HTTP ${response.statusCode}`)); return;
|
|
}
|
|
if (!/^application\/json(?:\s*;|$)/i.test(response.headers['content-type'] ?? '') ||
|
|
(response.headers['content-encoding'] && response.headers['content-encoding'] !== 'identity')) {
|
|
response.destroy(); reject(new Error('Peer must return uncompressed JSON')); return;
|
|
}
|
|
const chunks: Buffer[] = [];
|
|
let length = 0;
|
|
response.on('data', (chunk: Buffer) => {
|
|
length += chunk.length;
|
|
if (length > MAX_BODY) {
|
|
response.destroy(new Error('Peer response exceeds 2 MiB')); return;
|
|
}
|
|
chunks.push(chunk);
|
|
});
|
|
response.on('error', reject);
|
|
response.on('end', () => {
|
|
try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))); }
|
|
catch { reject(new Error('Invalid peer JSON')); }
|
|
});
|
|
});
|
|
request.on('error', reject);
|
|
request.end(encoded);
|
|
});
|
|
} finally { clearTimeout(timeout); this.active.delete(controller); }
|
|
}
|
|
}
|