import { lstat, readFile } from 'node:fs/promises'; import { isIP } from 'node:net'; import { randomUUID } from 'node:crypto'; import { z } from 'zod'; const MAX_CONFIG_BYTES = 4096; const MAX_RESPONSE_BYTES = 64 * 1024; const RPC_TIMEOUT_MS = 5000; const configSchema = z.object({ network: z.enum(['mainnet', 'stagenet']), url: z.string().min(1).max(2048), allowPrivateHttp: z.boolean(), }).strict(); const infoSchema = z.object({ status: z.literal('OK'), nettype: z.enum(['mainnet', 'stagenet', 'testnet']), mainnet: z.boolean(), stagenet: z.boolean(), testnet: z.boolean(), synchronized: z.boolean(), busy_syncing: z.boolean(), offline: z.boolean(), untrusted: z.boolean(), height: z.number().int().nonnegative().safe(), height_without_bootstrap: z.number().int().nonnegative().safe(), target_height: z.number().int().nonnegative().safe(), version: z.string().min(1).max(80), bootstrap_daemon_address: z.string().max(512), }).passthrough(); const envelopeSchema = z.object({ jsonrpc: z.literal('2.0'), id: z.string().uuid(), result: z.record(z.string(), z.unknown()), error: z.never().optional() }).strict(); export type MoneroDaemonProbeConfig = z.infer; export type MoneroDaemonProbeTransport = (url: string, init: RequestInit) => Promise; export type MoneroDaemonProbeResult = { network: 'mainnet' | 'stagenet'; height: number; version: string; synchronized: true; bootstrapProxyUsed: false; checkedAt: string; }; function isPrivateIp(host: string): boolean { const version = isIP(host); if (version === 4) { const [a, b] = host.split('.').map(Number); return a === 10 || a === 127 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 169 && b === 254); } if (version === 6) { const normalized = host.toLowerCase(); return normalized === '::1' || normalized.startsWith('fc') || normalized.startsWith('fd') || normalized.startsWith('fe8') || normalized.startsWith('fe9') || normalized.startsWith('fea') || normalized.startsWith('feb'); } return false; } function validateEndpoint(config: MoneroDaemonProbeConfig): URL { let url: URL; try { url = new URL(config.url); } catch { throw new Error('Invalid Monero daemon RPC endpoint'); } if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash || url.pathname !== '/json_rpc' || !url.port) { throw new Error('Monero daemon endpoint must be a bare /json_rpc URL with an explicit port'); } const host = url.hostname.replace(/^\[|\]$/g, ''); if (!isPrivateIp(host)) throw new Error('Monero daemon endpoint must use a private literal IP address'); if (url.protocol === 'http:' && !config.allowPrivateHttp) { throw new Error('Plain HTTP daemon RPC requires explicit private-network acknowledgement'); } return url; } export async function loadMoneroDaemonProbeConfig(env: NodeJS.ProcessEnv = process.env): Promise { const path = env.WIKIDECK_MONERO_DAEMON_CONFIG_FILE; if (!path) throw new Error('Protected Monero daemon probe config file is required'); try { const info = await lstat(path); if (!info.isFile() || info.isSymbolicLink() || info.size < 1 || info.size > MAX_CONFIG_BYTES) throw new Error(); if (process.platform !== 'win32' && (info.mode & 0o077) !== 0) throw new Error(); const config = configSchema.parse(JSON.parse(await readFile(path, 'utf8'))); validateEndpoint(config); return config; } catch { throw new Error('Invalid or inaccessible protected Monero daemon probe configuration'); } } async function readBounded(response: Response): Promise { if (!response.body) throw new Error('Monero daemon RPC response body is missing'); const reader = response.body.getReader(); const chunks: Uint8Array[] = []; let size = 0; try { for (;;) { const part = await reader.read(); if (part.done) break; size += part.value.byteLength; if (size > MAX_RESPONSE_BYTES) throw new Error('Monero daemon RPC response exceeds size limit'); chunks.push(part.value); } } finally { await reader.cancel().catch(() => {}); reader.releaseLock(); } return Buffer.concat(chunks).toString('utf8'); } /** Calls only monerod's read-only get_info method; no Wallet-RPC or transaction methods exist here. */ export async function probeMoneroDaemon(configInput: unknown, transport: MoneroDaemonProbeTransport = fetch, now = Date.now()): Promise { const config = configSchema.parse(configInput); const endpoint = validateEndpoint(config); if (!Number.isSafeInteger(now) || now < 0) throw new Error('Invalid daemon probe time'); const id = randomUUID(); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), RPC_TIMEOUT_MS); timeout.unref(); try { let response: Response; try { response = await transport(endpoint.href, { method: 'POST', redirect: 'error', signal: controller.signal, headers: { accept: 'application/json', 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id, method: 'get_info' }) }); } catch { throw new Error('Monero daemon RPC probe failed or timed out'); } if (response.status !== 200 || response.redirected || !/^application\/json(?:\s*;|$)/i.test(response.headers.get('content-type') ?? '')) { await response.body?.cancel(); throw new Error('Monero daemon RPC response rejected'); } let envelope: z.infer; try { envelope = envelopeSchema.parse(JSON.parse(await readBounded(response))); } catch { throw new Error('Invalid Monero daemon RPC response'); } if (envelope.id !== id) throw new Error('Monero daemon RPC response ID mismatch'); let info: z.infer; try { info = infoSchema.parse(envelope.result); } catch { throw new Error('Invalid Monero daemon RPC response'); } if (info.nettype !== config.network || info.mainnet !== (config.network === 'mainnet') || info.stagenet !== (config.network === 'stagenet') || info.testnet || info.height < 1 || info.height_without_bootstrap !== info.height || info.untrusted || info.offline || !info.synchronized || info.busy_syncing || info.bootstrap_daemon_address !== '') { throw new Error('Monero daemon network or synchronization state is not acceptable'); } return { network: config.network, height: info.height, version: info.version, synchronized: true, bootstrapProxyUsed: false, checkedAt: new Date(now).toISOString() }; } finally { clearTimeout(timeout); } }