Files
wikideck/server/testnet/peers.ts
T
2026-10-19 21:47:38 -04:00

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); }
}
}