184 lines
9.1 KiB
TypeScript
Executable File
184 lines
9.1 KiB
TypeScript
Executable File
import { createHash } from 'node:crypto';
|
|
import { lstat, readFile } from 'node:fs/promises';
|
|
import { createServer, type Server } from 'node:https';
|
|
import type { AddressInfo } from 'node:net';
|
|
import { isIP } from 'node:net';
|
|
import type { TLSSocket } from 'node:tls';
|
|
import { z } from 'zod';
|
|
|
|
const MAX_REQUEST_BYTES = 64 * 1024;
|
|
const MAX_RESPONSE_BYTES = 1024 * 1024;
|
|
const tlsFilesSchema = z.object({ certificateFile: z.string().min(1).max(4096), privateKeyFile: z.string().min(1).max(4096),
|
|
clientCaFile: z.string().min(1).max(4096) }).strict();
|
|
const gatewayConfigSchema = z.object({
|
|
listenHost: z.string().min(1).max(64),
|
|
listenPort: z.number().int().min(1024).max(65535),
|
|
walletRpcUrl: z.string().url().max(2048),
|
|
server: tlsFilesSchema,
|
|
clientFingerprints: z.array(z.string().regex(/^[a-f0-9]{64}$/)).min(1).max(32),
|
|
}).strict();
|
|
const envelopeSchema = z.object({ jsonrpc: z.literal('2.0'), id: z.union([z.string().max(128), z.number().int().safe()]),
|
|
method: z.string().max(64), params: z.record(z.string(), z.unknown()).optional() }).strict();
|
|
const uint32 = z.number().int().min(0).max(0xffff_ffff).safe();
|
|
const height = z.number().int().min(0).max(Number.MAX_SAFE_INTEGER).safe();
|
|
|
|
export type MoneroGatewayConfig = z.infer<typeof gatewayConfigSchema>;
|
|
|
|
function isPrivateBindAddress(value: string): boolean {
|
|
if (isIP(value) === 4) {
|
|
const octets = value.split('.').map(Number);
|
|
return octets[0] === 127 || octets[0] === 10 ||
|
|
(octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) ||
|
|
(octets[0] === 192 && octets[1] === 168);
|
|
}
|
|
if (isIP(value) === 6) {
|
|
const normalized = value.toLowerCase();
|
|
return normalized === '::1' || normalized.startsWith('fc') || normalized.startsWith('fd');
|
|
}
|
|
return false;
|
|
}
|
|
|
|
export async function loadMoneroRpcGatewayConfig(path: string): Promise<MoneroGatewayConfig> {
|
|
try {
|
|
const info = await lstat(path);
|
|
if (!info.isFile() || info.isSymbolicLink() || info.size < 1 || info.size > 16 * 1024) throw new Error();
|
|
if (process.platform !== 'win32' && (info.mode & 0o077) !== 0) throw new Error();
|
|
return gatewayConfigSchema.parse(JSON.parse(await readFile(path, 'utf8')));
|
|
} catch {
|
|
throw new Error('Invalid or inaccessible protected Monero RPC gateway configuration');
|
|
}
|
|
}
|
|
|
|
async function readPrivateFile(path: string): Promise<Buffer> {
|
|
const info = await lstat(path);
|
|
if (!info.isFile() || info.isSymbolicLink() || info.size < 1 || info.size > 64 * 1024) {
|
|
throw new Error('Monero gateway TLS material must be a bounded regular file');
|
|
}
|
|
if (process.platform !== 'win32' && (info.mode & 0o077) !== 0) {
|
|
throw new Error('Monero gateway TLS material permissions are too broad');
|
|
}
|
|
return readFile(path);
|
|
}
|
|
|
|
export function validateMoneroRpcGatewayRequest(value: unknown): string {
|
|
const request = envelopeSchema.parse(value);
|
|
const params = request.params ?? {};
|
|
switch (request.method) {
|
|
case 'get_height':
|
|
z.object({}).strict().parse(params);
|
|
break;
|
|
case 'get_address': {
|
|
z.object({ account_index: uint32, address_index: z.array(uint32).length(1) }).strict().parse(params);
|
|
break;
|
|
}
|
|
case 'validate_address':
|
|
z.object({ address: z.string().length(95) }).strict().parse(params);
|
|
break;
|
|
case 'create_address': {
|
|
const parsed = z.object({ account_index: uint32, label: z.string().min(1).max(128), count: z.literal(1) }).strict().parse(params);
|
|
if (/[\r\n\x00]/.test(parsed.label)) throw new Error('Invalid subaddress label');
|
|
break;
|
|
}
|
|
case 'get_transfers': {
|
|
const parsed = z.object({ in: z.literal(true), filter_by_height: z.literal(true),
|
|
min_height: height, max_height: height }).strict().parse(params);
|
|
if (parsed.max_height < parsed.min_height || parsed.max_height - parsed.min_height > 100_000) {
|
|
throw new Error('Wallet transfer scan range is invalid or too large');
|
|
}
|
|
break;
|
|
}
|
|
case 'incoming_transfers':
|
|
z.object({ transfer_type: z.literal('all'), account_index: uint32,
|
|
subaddr_indices: z.array(uint32).min(1).max(64) }).strict().parse(params);
|
|
break;
|
|
default:
|
|
throw new Error('Wallet RPC method is not allow-listed');
|
|
}
|
|
const body = JSON.stringify(request);
|
|
if (Buffer.byteLength(body) > MAX_REQUEST_BYTES) throw new Error('Wallet RPC request exceeds size limit');
|
|
return body;
|
|
}
|
|
|
|
async function forwardToLocalWallet(url: URL, body: string): Promise<{ status: number; contentType: string | undefined; body: Buffer }> {
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), 5000); timeout.unref();
|
|
try {
|
|
const response = await fetch(url, { method: 'POST', redirect: 'error', signal: controller.signal,
|
|
headers: { accept: 'application/json', 'content-type': 'application/json' }, body });
|
|
if (response.status !== 200 || response.redirected ||
|
|
!/^application\/json(?:\s*;|$)/i.test(response.headers.get('content-type') ?? '') || !response.body) {
|
|
await response.body?.cancel(); throw new Error('Local Wallet RPC response rejected');
|
|
}
|
|
const reader = response.body.getReader(), 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('Local Wallet RPC response exceeds size limit');
|
|
chunks.push(part.value);
|
|
}
|
|
} finally { await reader.cancel(); reader.releaseLock(); }
|
|
return { status: response.status, contentType: response.headers.get('content-type') ?? undefined,
|
|
body: Buffer.concat(chunks) };
|
|
} finally { clearTimeout(timeout); }
|
|
}
|
|
|
|
/**
|
|
* Private mTLS authorization gateway for Monero v0.18.x Wallet RPC. Bind it to loopback;
|
|
* run wallet-rpc with --disable-rpc-login only behind this process and a host firewall.
|
|
* Only allow-listed, schema-bounded methods are forwarded to a literal loopback upstream.
|
|
*/
|
|
export async function createMoneroRpcGateway(configInput: unknown): Promise<Server> {
|
|
const config = gatewayConfigSchema.parse(configInput);
|
|
if (!isPrivateBindAddress(config.listenHost)) throw new Error('Monero RPC gateway must bind to an explicit private interface IP');
|
|
let upstream: URL;
|
|
try { upstream = new URL(config.walletRpcUrl); } catch { throw new Error('Invalid local Wallet RPC URL'); }
|
|
if (upstream.protocol !== 'http:' || !['127.0.0.1', '[::1]'].includes(upstream.hostname) ||
|
|
upstream.pathname !== '/json_rpc' || upstream.username || upstream.password || upstream.search || upstream.hash) {
|
|
throw new Error('Wallet RPC gateway upstream must be fixed loopback-only HTTP');
|
|
}
|
|
const fingerprints = new Set(config.clientFingerprints);
|
|
const [cert, key, ca] = await Promise.all([readPrivateFile(config.server.certificateFile),
|
|
readPrivateFile(config.server.privateKeyFile), readPrivateFile(config.server.clientCaFile)]);
|
|
const server = createServer({ cert, key, ca, requestCert: true, rejectUnauthorized: true, minVersion: 'TLSv1.3' }, (request, response) => {
|
|
void (async () => {
|
|
if (request.method !== 'POST' || request.url !== '/json_rpc' ||
|
|
!/^application\/json(?:\s*;|$)/i.test(request.headers['content-type'] ?? '')) {
|
|
response.writeHead(404).end(); return;
|
|
}
|
|
const peer = (request.socket as TLSSocket).getPeerCertificate();
|
|
if (!peer.raw || !fingerprints.has(createHash('sha256').update(peer.raw).digest('hex'))) {
|
|
response.writeHead(403).end(); return;
|
|
}
|
|
const chunks: Buffer[] = []; let size = 0;
|
|
for await (const chunk of request) {
|
|
size += chunk.length;
|
|
if (size > MAX_REQUEST_BYTES) { response.writeHead(413).end(); return; }
|
|
chunks.push(Buffer.from(chunk));
|
|
}
|
|
let body: string;
|
|
try { body = validateMoneroRpcGatewayRequest(JSON.parse(Buffer.concat(chunks).toString('utf8'))); }
|
|
catch { response.writeHead(400).end(); return; }
|
|
let result: Awaited<ReturnType<typeof forwardToLocalWallet>>;
|
|
try { result = await forwardToLocalWallet(upstream, body); }
|
|
catch { response.writeHead(502).end(); return; }
|
|
response.writeHead(result.status, { 'content-type': result.contentType ?? 'application/json',
|
|
'cache-control': 'no-store', 'content-length': result.body.byteLength }).end(result.body);
|
|
})().catch(() => { if (!response.headersSent) response.writeHead(500).end(); else response.destroy(); });
|
|
});
|
|
server.maxConnections = 32;
|
|
server.headersTimeout = 5000;
|
|
server.requestTimeout = 10_000;
|
|
await new Promise<void>((resolve, reject) => {
|
|
server.once('error', reject);
|
|
server.listen(config.listenPort, config.listenHost, () => { server.off('error', reject); resolve(); });
|
|
});
|
|
const address = server.address() as AddressInfo | null;
|
|
if (!address || address.address !== config.listenHost || address.port !== config.listenPort) {
|
|
await new Promise<void>(resolve => server.close(() => resolve()));
|
|
throw new Error('Monero RPC gateway did not bind to the configured loopback address');
|
|
}
|
|
return server;
|
|
}
|