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 NftTransfer } from '../../shared/testnet.ts'; import { CHAIN_ID, blockSchema, transferId, transferSchema } 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(10_000), 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(10_000).nullable() }).strict(); const poolSchema = z.object({ transactions: z.array(transferSchema).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 | undefined; private closed = false; private readonly active = new Set(); 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 { 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: NftTransfer): Promise { const id = transferId(transferSchema.parse(tx)); const snapshot = this.ledger.snapshot(); const known = [...snapshot.pendingTransfers, ...snapshot.transfers].find(t => transferId(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 { // 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): Promise { 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 { 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 || end > 10_000 || (page.next !== null && (page.next !== end || end >= 10_000)) || (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.submitTransfer(tx); } catch { /* Independently reject; do not relay. */ } } } private async request(peer: Peer, path: string, body?: unknown): Promise { 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((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); } } }