Observer integration step
This commit is contained in:
@@ -10,19 +10,39 @@ 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 gatewayClientPolicySchema = z.object({
|
||||
fingerprint: z.string().regex(/^[a-f0-9]{64}$/),
|
||||
roles: z.array(z.enum(['observer', 'address-allocator'])).min(1).max(2),
|
||||
}).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();
|
||||
clientPolicies: z.array(gatewayClientPolicySchema).min(1).max(32),
|
||||
}).strict().superRefine((config, context) => {
|
||||
const fingerprints = config.clientPolicies.map(client => client.fingerprint);
|
||||
if (new Set(fingerprints).size !== fingerprints.length) {
|
||||
context.addIssue({ code: 'custom', message: 'Gateway client fingerprints must be unique', path: ['clientPolicies'] });
|
||||
}
|
||||
for (const client of config.clientPolicies) {
|
||||
if (new Set(client.roles).size !== client.roles.length) {
|
||||
context.addIssue({ code: 'custom', message: 'Gateway client roles must be unique', path: ['clientPolicies'] });
|
||||
}
|
||||
}
|
||||
});
|
||||
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>;
|
||||
export type MoneroGatewayRole = 'observer' | 'address-allocator';
|
||||
type GatewayMethod = 'get_height' | 'get_address' | 'validate_address' | 'create_address' | 'get_transfers' | 'incoming_transfers';
|
||||
const METHODS_BY_ROLE: Record<MoneroGatewayRole, readonly GatewayMethod[]> = {
|
||||
observer: ['get_height', 'get_address', 'get_transfers', 'incoming_transfers'],
|
||||
'address-allocator': ['get_address', 'validate_address', 'create_address'],
|
||||
};
|
||||
|
||||
function isPrivateBindAddress(value: string): boolean {
|
||||
if (isIP(value) === 4) {
|
||||
@@ -60,8 +80,12 @@ async function readPrivateFile(path: string): Promise<Buffer> {
|
||||
return readFile(path);
|
||||
}
|
||||
|
||||
export function validateMoneroRpcGatewayRequest(value: unknown): string {
|
||||
export function validateMoneroRpcGatewayRequest(value: unknown, roles: readonly MoneroGatewayRole[]): string {
|
||||
const request = envelopeSchema.parse(value);
|
||||
const parsedRoles = z.array(z.enum(['observer', 'address-allocator'])).min(1).max(2).parse(roles);
|
||||
if (new Set(parsedRoles).size !== parsedRoles.length) throw new Error('Monero RPC gateway client roles must be unique');
|
||||
const allowed = new Set(parsedRoles.flatMap(role => METHODS_BY_ROLE[role]));
|
||||
if (!allowed.has(request.method as GatewayMethod)) throw new Error('Wallet RPC method is not allowed for this client certificate');
|
||||
const params = request.params ?? {};
|
||||
switch (request.method) {
|
||||
case 'get_height':
|
||||
@@ -138,7 +162,7 @@ export async function createMoneroRpcGateway(configInput: unknown): Promise<Serv
|
||||
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 clientPolicies = new Map(config.clientPolicies.map(policy => [policy.fingerprint, policy.roles]));
|
||||
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) => {
|
||||
@@ -148,7 +172,8 @@ export async function createMoneroRpcGateway(configInput: unknown): Promise<Serv
|
||||
response.writeHead(404).end(); return;
|
||||
}
|
||||
const peer = (request.socket as TLSSocket).getPeerCertificate();
|
||||
if (!peer.raw || !fingerprints.has(createHash('sha256').update(peer.raw).digest('hex'))) {
|
||||
const roles = peer.raw ? clientPolicies.get(createHash('sha256').update(peer.raw).digest('hex')) : undefined;
|
||||
if (!roles) {
|
||||
response.writeHead(403).end(); return;
|
||||
}
|
||||
const chunks: Buffer[] = []; let size = 0;
|
||||
@@ -158,7 +183,7 @@ export async function createMoneroRpcGateway(configInput: unknown): Promise<Serv
|
||||
chunks.push(Buffer.from(chunk));
|
||||
}
|
||||
let body: string;
|
||||
try { body = validateMoneroRpcGatewayRequest(JSON.parse(Buffer.concat(chunks).toString('utf8'))); }
|
||||
try { body = validateMoneroRpcGatewayRequest(JSON.parse(Buffer.concat(chunks).toString('utf8')), roles); }
|
||||
catch { response.writeHead(400).end(); return; }
|
||||
let result: Awaited<ReturnType<typeof forwardToLocalWallet>>;
|
||||
try { result = await forwardToLocalWallet(upstream, body); }
|
||||
|
||||
Reference in New Issue
Block a user