monerod get_info
This commit is contained in:
Executable
+146
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user