monerod get_info

This commit is contained in:
2026-10-22 15:22:23 -04:00
parent 3f04c3afa9
commit d8780701e6
9 changed files with 315 additions and 10 deletions
+146
View File
@@ -0,0 +1,146 @@
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<typeof configSchema>;
export type MoneroDaemonProbeTransport = (url: string, init: RequestInit) => Promise<Response>;
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<MoneroDaemonProbeConfig> {
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<string> {
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<MoneroDaemonProbeResult> {
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<typeof envelopeSchema>;
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<typeof infoSchema>;
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);
}
}