diff --git a/README.md b/README.md index c475302..c0e0010 100644 --- a/README.md +++ b/README.md @@ -206,3 +206,13 @@ The configurable retry settings are: Setting `RETRY_CONDITION` to `""` disables retries. Setting `RETRY_MAX_ATTEMPTS` to `-1` causes it to retry indefinitely. Note, the token connector will make a total of `RETRY_MAX_ATTEMPTS` + 1 calls for a given retryable call (1 original attempt and `RETRY_MAX_ATTEMPTS` retries) + +## TLS + +Mutual TLS can be enabled by providing three environment variables: + +- `TLS_CA` +- `TLS_CERT` +- `TLS_KEY` + +Each should be a path to a file on disk. Providing all three environment variables will result in a token connector running with TLS enabled, and requiring all clients to provide client certificates signed by the certificate authority. diff --git a/src/event-stream/event-stream.service.ts b/src/event-stream/event-stream.service.ts index b83fb67..85655b4 100644 --- a/src/event-stream/event-stream.service.ts +++ b/src/event-stream/event-stream.service.ts @@ -22,7 +22,7 @@ import WebSocket from 'ws'; import { FFRequestIDHeader } from '../request-context/constants'; import { Context } from '../request-context/request-context.decorator'; import { IAbiMethod } from '../tokens/tokens.interfaces'; -import { basicAuth } from '../utils'; +import { getHttpRequestOptions, getWebsocketOptions } from '../utils'; import { Event, EventBatch, @@ -58,9 +58,7 @@ export class EventStreamSocket { this.disconnectDetected = false; this.closeRequested = false; - const auth = - this.username && this.password ? { auth: `${this.username}:${this.password}` } : undefined; - this.ws = new WebSocket(this.url, auth); + this.ws = new WebSocket(this.url, getWebsocketOptions(this.username, this.password)); this.ws .on('open', () => { if (this.disconnectDetected) { @@ -173,7 +171,7 @@ export class EventStreamService { } } headers[FFRequestIDHeader] = ctx.requestId; - const config = basicAuth(this.username, this.password); + const config = getHttpRequestOptions(this.username, this.password); config.headers = headers; return config; } diff --git a/src/health/health.controller.ts b/src/health/health.controller.ts index dc399d1..55ca86f 100644 --- a/src/health/health.controller.ts +++ b/src/health/health.controller.ts @@ -1,7 +1,7 @@ import { Controller, Get } from '@nestjs/common'; import { HealthCheckService, HealthCheck, HttpHealthIndicator } from '@nestjs/terminus'; import { BlockchainConnectorService } from '../tokens/blockchain.service'; -import { basicAuth } from '../utils'; +import { getHttpRequestOptions } from '../utils'; @Controller('health') export class HealthController { @@ -25,7 +25,7 @@ export class HealthController { this.http.pingCheck( 'ethconnect', `${this.blockchain.baseUrl}/status`, - basicAuth(this.blockchain.username, this.blockchain.password), + getHttpRequestOptions(this.blockchain.username, this.blockchain.password), ), ]); } diff --git a/src/main.ts b/src/main.ts index f5e9844..308b816 100644 --- a/src/main.ts +++ b/src/main.ts @@ -14,7 +14,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { ShutdownSignal, ValidationPipe } from '@nestjs/common'; +import { NestApplicationOptions, ShutdownSignal, ValidationPipe } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { NestFactory } from '@nestjs/core'; import { WsAdapter } from '@nestjs/platform-ws'; @@ -36,6 +36,7 @@ import { import { TokensService } from './tokens/tokens.service'; import { newContext } from './request-context/request-context.decorator'; import { AbiMapperService } from './tokens/abimapper.service'; +import { getNestOptions } from './utils'; const API_DESCRIPTION = `

All POST APIs are asynchronous. Listen for websocket notifications on /api/ws. @@ -50,7 +51,8 @@ export function getApiConfig() { } async function bootstrap() { - const app = await NestFactory.create(AppModule); + const app = await NestFactory.create(AppModule, getNestOptions()); + app.setGlobalPrefix('api/v1'); app.useWebSocketAdapter(new WsAdapter(app)); app.useGlobalPipes(new ValidationPipe({ whitelist: true })); diff --git a/src/tokens/blockchain.service.ts b/src/tokens/blockchain.service.ts index 3d3ce30..e321c4d 100644 --- a/src/tokens/blockchain.service.ts +++ b/src/tokens/blockchain.service.ts @@ -25,7 +25,7 @@ import { import axios, { AxiosRequestConfig, AxiosResponse } from 'axios'; import { lastValueFrom } from 'rxjs'; import { EventStreamReply } from '../event-stream/event-stream.interfaces'; -import { basicAuth } from '../utils'; +import { getHttpRequestOptions } from '../utils'; import { Context } from '../request-context/request-context.decorator'; import { FFRequestIDHeader } from '../request-context/constants'; import { EthConnectAsyncResponse, EthConnectReturn, IAbiMethod } from './tokens.interfaces'; @@ -80,7 +80,7 @@ export class BlockchainConnectorService { } } headers[FFRequestIDHeader] = ctx.requestId; - const config = basicAuth(this.username, this.password); + const config = getHttpRequestOptions(this.username, this.password); config.headers = headers; return config; } diff --git a/src/utils.ts b/src/utils.ts index 06f2876..2aa5db6 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,6 +1,52 @@ +import * as fs from 'fs'; +import * as https from 'https'; +import { NestApplicationOptions } from '@nestjs/common'; import { AxiosRequestConfig } from 'axios'; +import { ClientOptions } from 'ws'; -export const basicAuth = (username: string, password: string) => { +interface Certificates { + key: string; + cert: string; + ca: string; +} + +const getCertificates = (): Certificates | undefined => { + let key, cert, ca; + if ( + process.env['TLS_KEY'] === undefined || + process.env['TLS_CERT'] === undefined || + process.env['TLS_CA'] === undefined + ) { + return undefined; + } + try { + key = fs.readFileSync(process.env['TLS_KEY']).toString(); + cert = fs.readFileSync(process.env['TLS_CERT']).toString(); + ca = fs.readFileSync(process.env['TLS_CA']).toString(); + } catch (error) { + console.error(`Error reading certificates: ${error}`); + process.exit(-1); + } + return { key, cert, ca }; +}; + +export const getWebsocketOptions = (username: string, password: string): ClientOptions => { + const requestOptions: ClientOptions = {}; + if (username && username !== '' && password && password !== '') { + requestOptions.headers = { + Authorization: `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`, + }; + } + const certs = getCertificates(); + if (certs) { + requestOptions.ca = certs.ca; + requestOptions.cert = certs.cert; + requestOptions.key = certs.key; + } + return requestOptions; +}; + +export const getHttpRequestOptions = (username: string, password: string) => { const requestOptions: AxiosRequestConfig = {}; if (username !== '' && password !== '') { requestOptions.auth = { @@ -8,5 +54,18 @@ export const basicAuth = (username: string, password: string) => { password: password, }; } + const certs = getCertificates(); + if (certs) { + requestOptions.httpsAgent = new https.Agent({ ...certs, requestCert: true }); + } return requestOptions; }; + +export const getNestOptions = (): NestApplicationOptions => { + const options: NestApplicationOptions = {}; + const certs = getCertificates(); + if (certs) { + options.httpsOptions = certs; + } + return options; +};