diff --git a/packages/client/bin/cli.ts b/packages/client/bin/cli.ts index 9615897d3c6..02a192c2ecb 100755 --- a/packages/client/bin/cli.ts +++ b/packages/client/bin/cli.ts @@ -14,7 +14,7 @@ import { LevelDB } from '../src/execution/level.ts' import { generateVKTStateRoot } from '../src/util/vkt.ts' import { helpRPC, startRPCServers } from './startRPC.ts' -import { generateClientConfig, getArgs } from './utils.ts' +import { generateClientConfig, generateRpcConfigs, getArgs } from './utils.ts' import type * as http from 'http' import type { Block, BlockBytes } from '@ethereumjs/block' @@ -24,6 +24,7 @@ import type { AbstractLevel } from 'abstract-level' import type { Server as RPCServer } from 'jayson/promise/index.js' import type { Config } from '../src/config.ts' import type { Logger } from '../src/logging.ts' +import type { RpcConfig } from '../src/rpc/config.ts' import type { FullEthereumService } from '../src/service/index.ts' import type { ClientOpts } from '../src/types.ts' import type { RPCArgs } from './startRPC.ts' @@ -349,8 +350,26 @@ async function run() { const { config, customGenesisState, customGenesisStateRoot, metricsServer } = await generateClientConfig(args) + const rpcConfigs = generateRpcConfigs(config, args as RPCArgs) + logger = config.logger + await startClientAndServers( + config, + customGenesisState, + customGenesisStateRoot, + metricsServer, + rpcConfigs, + ) +} + +async function startClientAndServers( + config: Config, + customGenesisState: GenesisState | undefined, + customGenesisStateRoot: Uint8Array | undefined, + metricsServer: http.Server | undefined, + rpcConfigs: RpcConfig[], +) { // Do not wait for client to be fully started so that we can hookup SIGINT handling // else a SIGINT before may kill the process in unclean manner const clientStartPromise = startClient(config, { @@ -360,7 +379,7 @@ async function run() { .then((client) => { const servers: (RPCServer | http.Server)[] = args.rpc === true || args.rpcEngine === true || args.ws === true - ? startRPCServers(client, args as RPCArgs) + ? startRPCServers(client, rpcConfigs) : [] if ( client.config.chainCommon.gteHardfork(Hardfork.Paris) && diff --git a/packages/client/bin/repl.ts b/packages/client/bin/repl.ts index 3122920f908..89045090884 100644 --- a/packages/client/bin/repl.ts +++ b/packages/client/bin/repl.ts @@ -3,8 +3,8 @@ import process from 'process' import { createInlineClient } from '../src/util/index.ts' -import { startRPCServers } from './startRPC.ts' -import { generateClientConfig, getArgs } from './utils.ts' +import { type RPCArgs, startRPCServers } from './startRPC.ts' +import { generateClientConfig, generateRpcConfigs, getArgs } from './utils.ts' import type { Common, GenesisState } from '@ethereumjs/common' import type { Config } from '../src/config.ts' @@ -24,7 +24,8 @@ const setupClient = async ( args.dataDir ?? '', true, ) - const servers = startRPCServers(client, { + + const rpcArgs: RPCArgs = { rpc: true, rpcAddr: args.rpcAddr ?? '0.0.0.0', rpcPort: args.rpcPort ?? 8545, @@ -42,7 +43,9 @@ const setupClient = async ( jwtSecret: '', rpcEngineAuth: false, rpcCors: '', - }) + } + + const servers = startRPCServers(client, generateRpcConfigs(config, rpcArgs)) return { client, executionRPC: servers[0], engineRPC: servers[1] } } diff --git a/packages/client/bin/startRPC.ts b/packages/client/bin/startRPC.ts index 8e6e05f587a..77a081d7dfb 100644 --- a/packages/client/bin/startRPC.ts +++ b/packages/client/bin/startRPC.ts @@ -1,15 +1,6 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs' -import { - EthereumJSErrorWithoutCode, - bytesToUnprefixedHex, - hexToBytes, - randomBytes, -} from '@ethereumjs/util' - -import { RPCManager, saveReceiptsMethods } from '../src/rpc/index.ts' +import { RPCManager } from '../src/rpc/index.ts' import * as modules from '../src/rpc/modules/index.ts' import { - MethodConfig, createRPCServer, createRPCServerListener, createWsRPCServerListener, @@ -17,7 +8,7 @@ import { import type { Server } from 'jayson/promise/index.js' import type { EthereumClient } from '../src/client.ts' -import type { Config } from '../src/config.ts' +import type { RpcConfig } from '../src/rpc/config.ts' export type RPCArgs = { rpc: boolean @@ -39,191 +30,93 @@ export type RPCArgs = { rpcCors: string } -/** - * Returns a jwt secret from a provided file path, otherwise saves a randomly generated one to datadir if none already exists - */ -function parseJwtSecret(config: Config, jwtFilePath?: string): Uint8Array { - let jwtSecret: Uint8Array - const defaultJwtPath = `${config.datadir}/jwtsecret` - const usedJwtPath = jwtFilePath ?? defaultJwtPath - - // If jwtFilePath is provided, it should exist - if (jwtFilePath !== undefined && !existsSync(jwtFilePath)) { - throw EthereumJSErrorWithoutCode(`No file exists at provided jwt secret path=${jwtFilePath}`) - } - - if (jwtFilePath !== undefined || existsSync(defaultJwtPath)) { - const jwtSecretContents = readFileSync(jwtFilePath ?? defaultJwtPath, 'utf-8').trim() - const hexPattern = new RegExp(/^(0x|0X)?(?[a-fA-F0-9]+)$/, 'g') - const jwtSecretHex = hexPattern.exec(jwtSecretContents)?.groups?.jwtSecret - if (jwtSecretHex === undefined || jwtSecretHex.length !== 64) { - throw Error('Need a valid 256 bit hex encoded secret') - } - jwtSecret = hexToBytes(`0x${jwtSecretHex}`) - } else { - const folderExists = existsSync(config.datadir) - if (!folderExists) { - mkdirSync(config.datadir, { recursive: true }) - } - - jwtSecret = randomBytes(32) - writeFileSync(defaultJwtPath, bytesToUnprefixedHex(jwtSecret), {}) - config.logger?.info(`New Engine API JWT token created path=${defaultJwtPath}`) - } - config.logger?.info(`Using Engine API with JWT token authentication path=${usedJwtPath}`) - return jwtSecret -} - /** * Starts and returns enabled RPCServers */ -export function startRPCServers(client: EthereumClient, args: RPCArgs) { +export function startRPCServers(client: EthereumClient, rpcConfigs: RpcConfig[]): Server[] { const { config } = client const servers: Server[] = [] - const { - rpc, - rpcAddr, - rpcPort, - ws, - wsPort, - wsAddr, - rpcEngine, - rpcEngineAddr, - rpcEnginePort, - wsEngineAddr, - wsEnginePort, - jwtSecret: jwtSecretPath, - rpcEngineAuth, - rpcCors, - rpcDebug, - rpcDebugVerbose, - } = args + const manager = new RPCManager(client, config) - const { logger } = config - const jwtSecret = - rpcEngine && rpcEngineAuth ? parseJwtSecret(config, jwtSecretPath) : new Uint8Array(0) - let withEngineMethods = false - - if ((rpc || rpcEngine) && !config.saveReceipts) { - logger?.warn( - `Starting client without --saveReceipts might lead to interop issues with a CL especially if the CL intends to propose blocks, omitting methods=${saveReceiptsMethods}`, - ) - } - if (rpc || ws) { - let rpcHttpServer - withEngineMethods = rpcEngine && rpcEnginePort === rpcPort && rpcEngineAddr === rpcAddr - - const { server, namespaces, methods } = createRPCServer(manager, { - methodConfig: withEngineMethods ? MethodConfig.WithEngine : MethodConfig.WithoutEngine, - rpcDebugVerbose, - rpcDebug, - logger, - }) - servers.push(server) - - if (rpc) { - rpcHttpServer = createRPCServerListener({ - RPCCors: rpcCors, - server, - withEngineMiddleware: - withEngineMethods && rpcEngineAuth - ? { - jwtSecret, - unlessFn: (req: any) => - Array.isArray(req.body) - ? req.body.some((r: any) => r.method.includes('engine_')) === false - : req.body.method.includes('engine_') === false, - } - : undefined, + const serverGroups: Map = new Map() + + for (const rpcConfig of rpcConfigs) { + // unique key for each server: eth-rpc (http & ws), engine-rpc (http & ws) + // used to create a single rpc server for each transport type + const key = `${rpcConfig.type}-${rpcConfig.methodConfig}` + + let serverEntry = serverGroups.get(key) + + if (!serverEntry) { + const { server, namespaces, methods } = createRPCServer(manager, { + methodConfig: rpcConfig.methodConfig, + rpcDebug: rpcConfig.debug, + rpcDebugVerbose: rpcConfig.debugVerbose, + logger: config.logger, }) - rpcHttpServer.listen(rpcPort, rpcAddr) - logger?.info( - `Started JSON RPC Server address=http://${rpcAddr}:${rpcPort} namespaces=${namespaces}${ - withEngineMethods ? ' rpcEngineAuth=' + rpcEngineAuth.toString() : '' - }`, - ) - logger?.debug( - `Methods available at address=http://${rpcAddr}:${rpcPort} namespaces=${namespaces} methods=${Object.keys( + + servers.push(server) + serverGroups.set(key, { rpcConfig, server }) + serverEntry = { rpcConfig, server } + + config.logger?.info( + `Created RPCServer for type=${rpcConfig.type} methodConfig=${rpcConfig.methodConfig} namespaces=${namespaces} methods=${Object.keys( methods, ).join(',')}`, ) } - if (ws) { - const opts: any = { - rpcCors, + + const { server } = serverEntry + // middleware for engine auth + const middleware = + rpcConfig.engineAuth && rpcConfig.jwtSecret + ? { + jwtSecret: rpcConfig.jwtSecret, + unlessFn: (req: any) => + Array.isArray(req.body) + ? req.body.some((r: any) => r.method.includes('engine_')) === false + : req.body.method.includes('engine_') === false, + } + : undefined + + if (rpcConfig.transport === 'http') { + const httpServer = createRPCServerListener({ + RPCCors: rpcConfig.cors, server, - withEngineMiddleware: withEngineMethods && rpcEngineAuth ? { jwtSecret } : undefined, - } - if (rpcAddr === wsAddr && rpcPort === wsPort) { - // We want to load the websocket upgrade request to the same server - opts.httpServer = rpcHttpServer - } + withEngineMiddleware: middleware, + }) + httpServer.listen(rpcConfig.port, rpcConfig.address) - const rpcWsServer = createWsRPCServerListener(opts) - if (rpcWsServer) rpcWsServer.listen(wsPort) - logger?.info( - `Started JSON RPC Server address=ws://${wsAddr}:${wsPort} namespaces=${namespaces}${ - withEngineMethods ? ` rpcEngineAuth=${rpcEngineAuth}` : '' + config.logger?.info( + `Started JSON RPC Server address=http://${rpcConfig.address}:${rpcConfig.port} type=${rpcConfig.type} ${ + rpcConfig.engineAuth ? 'engineAuth=true' : '' }`, ) - logger?.debug( - `Methods available at address=ws://${wsAddr}:${wsPort} namespaces=${namespaces} methods=${Object.keys( - methods, - ).join(',')}`, - ) } - } - if (rpcEngine && !(rpc && rpcPort === rpcEnginePort && rpcAddr === rpcEngineAddr)) { - const { server, namespaces, methods } = createRPCServer(manager, { - methodConfig: MethodConfig.EngineOnly, - rpcDebug, - rpcDebugVerbose, - logger, - }) - servers.push(server) - const rpcHttpServer = createRPCServerListener({ - RPCCors: rpcCors, - server, - withEngineMiddleware: rpcEngineAuth - ? { - jwtSecret, - } - : undefined, - }) - rpcHttpServer.listen(rpcEnginePort, rpcEngineAddr) - logger?.info( - `Started JSON RPC server address=http://${rpcEngineAddr}:${rpcEnginePort} namespaces=${namespaces} rpcEngineAuth=${rpcEngineAuth}`, - ) - logger?.debug( - `Methods available at address=http://${rpcEngineAddr}:${rpcEnginePort} namespaces=${namespaces} methods=${Object.keys( - methods, - ).join(',')}`, - ) - - if (ws) { - const opts: any = { - rpcCors, + if (rpcConfig.transport === 'ws') { + const wsOpts: any = { + RPCCors: rpcConfig.cors, server, - withEngineMiddleware: rpcEngineAuth ? { jwtSecret } : undefined, + withEngineMiddleware: middleware, } - if (rpcEngineAddr === wsEngineAddr && rpcEnginePort === wsEnginePort) { - // We want to load the websocket upgrade request to the same server - opts.httpServer = rpcHttpServer + // Attach to existing HTTP server for upgrades if same port/address + const httpKey = `${rpcConfig.type}-${rpcConfig.methodConfig}` + if (rpcConfig.address === rpcConfig.address && serverGroups.has(httpKey)) { + wsOpts.httpServer = serverGroups.get(httpKey)?.server } - const rpcWsServer = createWsRPCServerListener(opts) - if (rpcWsServer) rpcWsServer.listen(wsEnginePort, wsEngineAddr) - logger?.info( - `Started JSON RPC Server address=ws://${wsEngineAddr}:${wsEnginePort} namespaces=${namespaces} rpcEngineAuth=${rpcEngineAuth}`, - ) - logger?.debug( - `Methods available at address=ws://${wsEngineAddr}:${wsEnginePort} namespaces=${namespaces} methods=${Object.keys( - methods, - ).join(',')}`, - ) + const wsServer = createWsRPCServerListener(wsOpts) + if (wsServer) { + wsServer.listen(rpcConfig.port) + config.logger?.info( + `Started JSON RPC WS Server address=ws://${rpcConfig.address}:${rpcConfig.port} namespaces=${rpcConfig.type} ${ + rpcConfig.engineAuth ? 'engineAuth=true' : '' + }`, + ) + } } } diff --git a/packages/client/bin/utils.ts b/packages/client/bin/utils.ts index 2c034967fad..876b1402e9c 100644 --- a/packages/client/bin/utils.ts +++ b/packages/client/bin/utils.ts @@ -49,13 +49,15 @@ import { hideBin } from 'yargs/helpers' import { Config, SyncMode } from '../src/config.ts' import { getLogger } from '../src/logging.ts' import { Event } from '../src/types.ts' -import { parseMultiaddrs } from '../src/util/index.ts' +import { MethodConfig, parseJwtSecret, parseMultiaddrs } from '../src/util/index.ts' import { setupMetrics } from '../src/util/metrics.ts' import type { CustomCrypto, GenesisState, GethGenesis } from '@ethereumjs/common' import type { Address, PrefixedHexString } from '@ethereumjs/util' import type { Logger } from '../src/logging.ts' +import { RpcConfig } from '../src/rpc/config.ts' import type { ClientOpts } from '../src/types.ts' +import type { RPCArgs } from './startRPC.ts' export type Account = [address: Address, privateKey: Uint8Array] @@ -896,3 +898,99 @@ export async function generateClientConfig(args: ClientOpts) { return { config, customGenesisState, customGenesisStateRoot, metricsServer, common } } + +export function generateRpcConfigs(config: Config, args: RPCArgs): RpcConfig[] { + const servers: RpcConfig[] = [] + + const jwtSecret = + args.rpcEngine && args.rpcEngineAuth + ? parseJwtSecret(config, args.jwtSecret) + : new Uint8Array(0) + + // eth/engine shared + const withEngineMethods = + args.rpcEngine && + args.rpc && + args.rpcEnginePort === args.rpcPort && + args.rpcEngineAddr === args.rpcAddr + + if (args.rpc || args.ws) { + const methodConfig = withEngineMethods ? MethodConfig.WithEngine : MethodConfig.WithoutEngine + + if (args.rpc) { + servers.push( + new RpcConfig({ + type: 'eth', + transport: 'http', + methodConfig, + address: args.rpcAddr, + port: args.rpcPort, + engineAuth: withEngineMethods && args.rpcEngineAuth, + jwtSecret: withEngineMethods ? jwtSecret : undefined, + debug: args.rpcDebug, + debugVerbose: args.rpcDebugVerbose, + cors: args.rpcCors, + }), + ) + } + + if (args.ws) { + servers.push( + new RpcConfig({ + type: 'eth', + transport: 'ws', + methodConfig, + address: args.wsAddr, + port: args.wsPort, + engineAuth: withEngineMethods && args.rpcEngineAuth, + jwtSecret: withEngineMethods ? jwtSecret : undefined, + debug: args.rpcDebug, + debugVerbose: args.rpcDebugVerbose, + cors: args.rpcCors, + }), + ) + } + } + + const engineSeparate = + args.rpcEngine && + !(args.rpc && args.rpcPort === args.rpcEnginePort && args.rpcAddr === args.rpcEngineAddr) + + if (engineSeparate) { + const methodConfig = MethodConfig.EngineOnly + + servers.push( + new RpcConfig({ + type: 'engine', + transport: 'http', + methodConfig, + address: args.rpcEngineAddr, + port: args.rpcEnginePort, + engineAuth: args.rpcEngineAuth, + jwtSecret, + debug: args.rpcDebug, + debugVerbose: args.rpcDebugVerbose, + cors: args.rpcCors, + }), + ) + + if (args.ws) { + servers.push( + new RpcConfig({ + type: 'engine', + transport: 'ws', + methodConfig, + address: args.wsEngineAddr, + port: args.wsEnginePort, + engineAuth: args.rpcEngineAuth, + jwtSecret, + debug: args.rpcDebug, + debugVerbose: args.rpcDebugVerbose, + cors: args.rpcCors, + }), + ) + } + } + + return servers +} diff --git a/packages/client/src/rpc/config.ts b/packages/client/src/rpc/config.ts new file mode 100644 index 00000000000..33979050797 --- /dev/null +++ b/packages/client/src/rpc/config.ts @@ -0,0 +1,84 @@ +import type { MethodConfig } from '../util/rpc.ts' + +export const RPCNamespace = { + eth: 'eth', // untrusted + engine: 'engine', // trusted +} as const + +export type RPCNamespace = (typeof RPCNamespace)[keyof typeof RPCNamespace] + +export const RPCTransport = { + ws: 'ws', + http: 'http', +} as const + +export type RPCTransport = (typeof RPCTransport)[keyof typeof RPCTransport] + +export class RpcConfig { + public static readonly DEFAULT_RPC_ADDR = '0.0.0.0' + public static readonly DEFAULT_ENGINE_ADDR = '0.0.0.0' + public static readonly DEFAULT_WS_ADDR = '0.0.0.0' + public static readonly DEFAULT_WS_ENGINE_ADDR = '0.0.0.0' + public static readonly DEFAULT_RPC_PORT = 8545 + public static readonly DEFAULT_ENGINE_PORT = 8551 + public static readonly DEFAULT_WS_PORT = 0 + public static readonly DEFAULT_WS_ENGINE_PORT = 8552 + public static readonly DEFAULT_RPC_DEBUG = 'eth' + public static readonly DEFAULT_RPC_DEBUG_VERBOSE = 'false' + public static readonly DEFAULT_HELP_RPC = false + public static readonly DEFAULT_RPC_ENGINE_AUTH = false + public static readonly DEFAULT_CORS = '' + // public static readonly RPC_ETH_MAXPAYLOAD_DEFAULT + // public static readonly RPC_ENGINE_MAXPAYLOAD_DEFAULT + + public readonly type: RPCNamespace + public readonly transport: RPCTransport + public readonly methodConfig: MethodConfig + public readonly address: string + public readonly port: number + public readonly debug: string + public readonly debugVerbose: string + public readonly jwtSecret: Uint8Array | undefined + public readonly engineAuth: boolean + public readonly cors: string + + constructor(options: { + type: RPCNamespace + transport: RPCTransport + methodConfig: MethodConfig + address?: string + port?: number + jwtSecret: Uint8Array | undefined + engineAuth?: boolean + cors?: string + debug?: string + debugVerbose?: string + }) { + this.type = options.type + this.transport = options.transport + this.methodConfig = options.methodConfig + this.address = options.address ?? this.getDefaultAddress(this.type, this.transport) + this.port = options.port ?? this.getDefaultPort(this.type, this.transport) + this.debug = options.debug ?? RpcConfig.DEFAULT_RPC_DEBUG + this.debugVerbose = options.debugVerbose ?? RpcConfig.DEFAULT_RPC_DEBUG_VERBOSE + this.cors = options.cors ?? RpcConfig.DEFAULT_CORS + this.engineAuth = options.engineAuth ?? RpcConfig.DEFAULT_RPC_ENGINE_AUTH + this.jwtSecret = options.jwtSecret + } + + getDefaultAddress(type: RPCNamespace, transport: RPCTransport): string { + if (type === 'eth') { + return transport === 'http' ? RpcConfig.DEFAULT_RPC_ADDR : RpcConfig.DEFAULT_WS_ADDR + } else { + return transport === 'http' ? RpcConfig.DEFAULT_ENGINE_ADDR : RpcConfig.DEFAULT_WS_ENGINE_ADDR + } + } + + getDefaultPort(type: RPCNamespace, transport: RPCTransport): number { + if (type === 'eth') { + return transport === 'http' ? RpcConfig.DEFAULT_RPC_PORT : RpcConfig.DEFAULT_WS_PORT + } else { + return transport === 'http' ? RpcConfig.DEFAULT_ENGINE_PORT : RpcConfig.DEFAULT_WS_ENGINE_PORT + } + } +} diff --git a/packages/client/src/util/rpc.ts b/packages/client/src/util/rpc.ts index 3ebf507622f..d56658def7f 100644 --- a/packages/client/src/util/rpc.ts +++ b/packages/client/src/util/rpc.ts @@ -1,5 +1,12 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs' import { createServer } from 'http' import { inspect } from 'util' +import { + EthereumJSErrorWithoutCode, + bytesToUnprefixedHex, + hexToBytes, + randomBytes, +} from '@ethereumjs/util' import bodyParser from 'body-parser' import Connect from 'connect' import cors from 'cors' @@ -8,6 +15,7 @@ import jayson from 'jayson/promise/index.js' import { jwt } from '../ext/jwt-simple.ts' import type { IncomingMessage } from 'connect' +import type { Config } from '../config.ts' import type { TAlgorithm } from '../ext/jwt-simple.ts' import type { Logger } from '../logging.ts' import type { RPCManager } from '../rpc/index.ts' @@ -243,3 +251,38 @@ export function createWsRPCServerListener(opts: CreateWSServerOpts): jayson.Http // Only return something if a new server was created return !opts.httpServer ? httpServer : undefined } + +/** + * Returns a jwt secret from a provided file path, otherwise saves a randomly generated one to datadir if none already exists + */ +export function parseJwtSecret(config: Config, jwtFilePath?: string): Uint8Array { + let jwtSecret: Uint8Array + const defaultJwtPath = `${config.datadir}/jwtsecret` + const usedJwtPath = jwtFilePath ?? defaultJwtPath + + // If jwtFilePath is provided, it should exist + if (jwtFilePath !== undefined && !existsSync(jwtFilePath)) { + throw EthereumJSErrorWithoutCode(`No file exists at provided jwt secret path=${jwtFilePath}`) + } + + if (jwtFilePath !== undefined || existsSync(defaultJwtPath)) { + const jwtSecretContents = readFileSync(jwtFilePath ?? defaultJwtPath, 'utf-8').trim() + const hexPattern = new RegExp(/^(0x|0X)?(?[a-fA-F0-9]+)$/, 'g') + const jwtSecretHex = hexPattern.exec(jwtSecretContents)?.groups?.jwtSecret + if (jwtSecretHex === undefined || jwtSecretHex.length !== 64) { + throw Error('Need a valid 256 bit hex encoded secret') + } + jwtSecret = hexToBytes(`0x${jwtSecretHex}`) + } else { + const folderExists = existsSync(config.datadir) + if (!folderExists) { + mkdirSync(config.datadir, { recursive: true }) + } + + jwtSecret = randomBytes(32) + writeFileSync(defaultJwtPath, bytesToUnprefixedHex(jwtSecret), {}) + config.logger?.info(`New Engine API JWT token created path=${defaultJwtPath}`) + } + config.logger?.info(`Using Engine API with JWT token authentication path=${usedJwtPath}`) + return jwtSecret +}