diff --git a/packages/insomnia-smoke-test/server/grpc.ts b/packages/insomnia-smoke-test/server/grpc.ts index 943d025ec69..94f3c98979e 100644 --- a/packages/insomnia-smoke-test/server/grpc.ts +++ b/packages/insomnia-smoke-test/server/grpc.ts @@ -2,10 +2,11 @@ /* eslint-disable camelcase */ // inspiration: https://github.com/grpc/grpc/blob/master/examples/node/dynamic_codegen/route_guide/route_guide_server.js import * as grpc from '@grpc/grpc-js'; +import { ServerCredentials } from '@grpc/grpc-js'; import { HandleCall } from '@grpc/grpc-js/build/src/server-call'; import * as protoLoader from '@grpc/proto-loader'; import { addReflection } from '@ravanallc/grpc-server-reflection'; -import fs from 'fs'; +import fs, { readFileSync } from 'fs'; import path from 'path'; const PROTO_PATH = path.resolve('../../packages/insomnia/src/network/grpc/__fixtures__/library/route_guide.proto'); @@ -192,8 +193,7 @@ export const startGRPCServer = (port: number) => { const server = new grpc.Server(); // Enable reflection - const descriptorSet = '../../packages/insomnia-smoke-test/fixtures/route_guide.bin'; - addReflection(server, descriptorSet); + addReflection(server, '../../packages/insomnia-smoke-test/fixtures/route_guide.bin'); // @ts-expect-error generated from proto file server.addService(routeguide.RouteGuide.service, { @@ -218,5 +218,46 @@ export const startGRPCServer = (port: number) => { resolve(); }); }); + const serverWithTLS = new grpc.Server(); + + // Enable reflection + addReflection(serverWithTLS, '../../packages/insomnia-smoke-test/fixtures/route_guide.bin'); + + // @ts-expect-error generated from proto file + serverWithTLS.addService(routeguide.RouteGuide.service, { + getFeature: getFeature, + listFeatures: listFeatures, + recordRoute: recordRoute, + routeChat: routeChat, + }); + const rootCert = readFileSync(path.join(__dirname, '../fixtures/certificates/rootCA.pem')); + const serverCert = readFileSync(path.join(__dirname, '../fixtures/certificates/localhost.pem')); + const serverKey = readFileSync(path.join(__dirname, '../fixtures/certificates/localhost-key.pem')); + const serverCredentials = ServerCredentials.createSsl( + rootCert, + [ + { + cert_chain: serverCert, + private_key: serverKey, + }, + ], + true + ); + serverWithTLS.bindAsync('localhost:50052', serverCredentials, error => { + if (error) { + return reject(error); + } + + const dbPath = '../../packages/insomnia/src/network/grpc/__fixtures__/library/route_guide_db.json'; + fs.readFile(path.resolve(dbPath), (err, data) => { + if (err) { + throw err; + } + featureList = JSON.parse(data.toString()); + console.log('Listening at grpcs://localhost:50052 for route_guide.proto'); + serverWithTLS.start(); + resolve(); + }); + }); }); }; diff --git a/packages/insomnia/src/main/ipc/grpc.ts b/packages/insomnia/src/main/ipc/grpc.ts index e29bc5ffd5a..cadb5d2c541 100644 --- a/packages/insomnia/src/main/ipc/grpc.ts +++ b/packages/insomnia/src/main/ipc/grpc.ts @@ -8,9 +8,9 @@ import { Code, ConnectError, createPromiseClient } from '@connectrpc/connect'; import { createConnectTransport } from '@connectrpc/connect-node'; import { type Call, + ChannelCredentials, type ClientDuplexStream, type ClientReadableStream, - credentials, makeGenericClientConstructor, Metadata, type ServiceError, @@ -42,6 +42,10 @@ const grpcCalls = new Map(); export interface GrpcIpcRequestParams { request: RenderedGrpcRequest; + clientCert?: string; + clientKey?: string; + caCertificate?: string; + rejectUnauthorized: boolean; } export interface GrpcIpcMessageParams { @@ -200,16 +204,20 @@ const getMethodsFromReflectionServer = async ( const getMethodsFromReflection = async ( host: string, metadata: GrpcRequestHeader[], - reflectionApi: GrpcRequest['reflectionApi'] + rejectUnauthorized: boolean, + reflectionApi: GrpcRequest['reflectionApi'], + clientCert?: string, + clientKey?: string, + caCertificate?: string, ): Promise => { if (reflectionApi.enabled) { return getMethodsFromReflectionServer(reflectionApi); } try { - const { url, enableTls } = parseGrpcUrl(host); + const { url } = parseGrpcUrl(host); const client = new grpcReflection.Client( url, - enableTls ? credentials.createSsl() : credentials.createInsecure(), + getChannelCredentials({ url: host, caCertificate, clientCert, clientKey, rejectUnauthorized }), grpcOptions, filterDisabledMetaData(metadata) ); @@ -262,13 +270,21 @@ const getMethodsFromReflection = async ( export const loadMethodsFromReflection = async (options: { url: string; metadata: GrpcRequestHeader[]; + rejectUnauthorized: boolean; reflectionApi: GrpcRequest['reflectionApi']; + clientCert?: string; + clientKey?: string; + caCertificate?: string; }): Promise => { invariant(options.url, 'gRPC request url not provided'); const methods = await getMethodsFromReflection( options.url, options.metadata, - options.reflectionApi + options.rejectUnauthorized, + options.reflectionApi, + options.clientCert, + options.clientKey, + options.caCertificate, ); return methods.map(method => ({ type: getMethodType(method), @@ -313,10 +329,12 @@ export const getSelectedMethod = async ( invariant(methods, 'No methods found'); return methods.find(c => c.path === request.protoMethodName); } + const settings = await models.settings.getOrCreate(); const methods = await getMethodsFromReflection( request.url, request.metadata, - request.reflectionApi + settings.validateSSL, + request.reflectionApi, ); invariant(methods, 'No reflection methods found'); return methods.find(c => c.path === request.protoMethodName); @@ -343,9 +361,22 @@ const isEnumDefinition = (definition: AnyDefinition): definition is EnumTypeDefi return (definition as EnumTypeDefinition).format === 'Protocol Buffer 3 EnumDescriptorProto'; }; +const getChannelCredentials = ({ url, rejectUnauthorized, clientCert, clientKey, caCertificate }: { url: string; rejectUnauthorized: boolean; clientCert?: string; clientKey?: string; caCertificate?: string }): ChannelCredentials => { + if (url.toLowerCase().startsWith('grpc:')) { + return ChannelCredentials.createInsecure(); + } + if (caCertificate && clientKey && clientCert) { + return ChannelCredentials.createSsl(Buffer.from(caCertificate, 'utf8'), Buffer.from(clientKey, 'utf8'), Buffer.from(clientCert, 'utf8'), { rejectUnauthorized }); + } + if (caCertificate) { + return ChannelCredentials.createSsl(Buffer.from(caCertificate, 'utf8'), null, null, { rejectUnauthorized }); + } + return ChannelCredentials.createSsl(null, null, null, { rejectUnauthorized }); +}; + export const start = ( event: IpcMainEvent, - { request }: GrpcIpcRequestParams, + { request, rejectUnauthorized, clientCert, clientKey, caCertificate }: GrpcIpcRequestParams, ) => { getSelectedMethod(request)?.then(method => { if (!method) { @@ -354,15 +385,16 @@ export const start = ( } const methodType = getMethodType(method); // Create client - const { url, enableTls } = parseGrpcUrl(request.url); + const { url } = parseGrpcUrl(request.url); + if (!url) { event.reply('grpc.error', request._id, new Error('URL not specified')); return undefined; } - console.log(`[gRPC] connecting to url=${url} ${enableTls ? 'with' : 'without'} TLS`); // @ts-expect-error -- TSCONVERSION second argument should be provided, send an empty string? Needs testing const Client = makeGenericClientConstructor({}); - const client = new Client(url, enableTls ? credentials.createSsl() : credentials.createInsecure()); + const creds = getChannelCredentials({ url: request.url, rejectUnauthorized, clientCert, clientKey, caCertificate }); + const client = new Client(url, creds); if (!client) { return; } diff --git a/packages/insomnia/src/network/grpc/parse-grpc-url.ts b/packages/insomnia/src/network/grpc/parse-grpc-url.ts index 67765e5b50f..9535a1cb23a 100644 --- a/packages/insomnia/src/network/grpc/parse-grpc-url.ts +++ b/packages/insomnia/src/network/grpc/parse-grpc-url.ts @@ -9,5 +9,5 @@ export const parseGrpcUrl = (grpcUrl: string): { url: string; enableTls: boolean if (lower.startsWith('grpcs://')) { return { url: lower.slice(8), enableTls: true }; } - return { url: lower, enableTls: false }; + return { url: lower, enableTls: true }; }; diff --git a/packages/insomnia/src/ui/components/panes/grpc-request-pane.tsx b/packages/insomnia/src/ui/components/panes/grpc-request-pane.tsx index bfa30a9096b..b86c65805c9 100644 --- a/packages/insomnia/src/ui/components/panes/grpc-request-pane.tsx +++ b/packages/insomnia/src/ui/components/panes/grpc-request-pane.tsx @@ -1,3 +1,4 @@ +import { readFile } from 'fs/promises'; import React, { type FunctionComponent, useRef, useState } from 'react'; import { Tab, TabList, TabPanel, Tabs } from 'react-aria-components'; import { useParams, useRouteLoaderData } from 'react-router-dom'; @@ -11,11 +12,14 @@ import type { GrpcMethodType } from '../../../main/ipc/grpc'; import * as models from '../../../models'; import type { GrpcRequestHeader } from '../../../models/grpc-request'; import { queryAllWorkspaceUrls } from '../../../models/helpers/query-all-workspace-urls'; +import { urlMatchesCertHost } from '../../../network/url-matches-cert-host'; import { tryToInterpolateRequestOrShowRenderErrorModal } from '../../../utils/try-interpolate'; +import { setDefaultProtocol } from '../../../utils/url/protocol'; import { useRequestPatcher } from '../../hooks/use-request'; import { useActiveRequestSyncVCSVersion, useGitVCSVersion } from '../../hooks/use-vcs-version'; import type { GrpcRequestState } from '../../routes/debug'; import type { GrpcRequestLoaderData } from '../../routes/request'; +import { useRootLoaderData } from '../../routes/root'; import type { WorkspaceLoaderData } from '../../routes/workspace'; import { GrpcSendButton } from '../buttons/grpc-send-button'; import { CodeEditor, type CodeEditorHandle } from '../codemirror/code-editor'; @@ -53,7 +57,7 @@ export const GrpcRequestPane: FunctionComponent = ({ reloadRequests, }) => { const { activeRequest } = useRouteLoaderData('request/:requestId') as GrpcRequestLoaderData; - + const { settings } = useRootLoaderData(); const [isProtoModalOpen, setIsProtoModalOpen] = useState(false); const { requestMessages, running, methods } = grpcState; useMount(async () => { @@ -98,7 +102,18 @@ export const GrpcRequestPane: FunctionComponent = ({ purpose: 'send', skipBody: canClientStream(methodType), }); - window.main.grpc.start({ request }); + const workspaceClientCertificates = await models.clientCertificate.findByParentId(workspaceId); + const clientCertificate = workspaceClientCertificates.find(c => !c.disabled && urlMatchesCertHost(setDefaultProtocol(c.host, 'grpc:'), request.url, false)); + const caCertificatePath = (await models.caCertificate.findByParentId(workspaceId))?.path; + const clientCert = await readFile(clientCertificate?.cert || '', 'utf8'); + const clientKey = await readFile(clientCertificate?.key || '', 'utf8'); + window.main.grpc.start({ + request, + rejectUnauthorized: settings.validateSSL, + clientCert, + clientKey, + caCertificate: caCertificatePath ? await readFile(caCertificatePath, 'utf8') : undefined, + }); setGrpcState({ ...grpcState, requestMessages: [], @@ -189,7 +204,7 @@ export const GrpcRequestPane: FunctionComponent = ({ disabled={!activeRequest.url} onClick={async () => { try { - const rendered = + let rendered = await tryToInterpolateRequestOrShowRenderErrorModal({ request: activeRequest, environmentId, @@ -199,6 +214,18 @@ export const GrpcRequestPane: FunctionComponent = ({ reflectionApi: activeRequest.reflectionApi, }, }); + const workspaceClientCertificates = await models.clientCertificate.findByParentId(workspaceId); + const clientCertificate = workspaceClientCertificates.find(c => !c.disabled); + const caCertificatePath = (await models.caCertificate.findByParentId(workspaceId))?.path; + const clientCert = await readFile(clientCertificate?.cert || '', 'utf8'); + const clientKey = await readFile(clientCertificate?.key || '', 'utf8'); + rendered = { + ...rendered, + rejectUnauthorized: settings.validateSSL, + clientCert, + clientKey, + caCertificate: caCertificatePath ? await readFile(caCertificatePath, 'utf8') : undefined, + }; const methods = await window.main.grpc.loadMethodsFromReflection(rendered); setGrpcState({ ...grpcState, methods }); patchRequest(requestId, { protoFileId: '', protoMethodName: '' });