From bcda595c84a1a6805c20375a45b318de3e092319 Mon Sep 17 00:00:00 2001 From: Peter Somogyvari Date: Wed, 8 Jul 2020 12:19:26 -0700 Subject: [PATCH] feat(api-server): TLS, mTLS support This is not really something that we expect to be used in production due to the prevalence of load balancers and reverse proxies in most production scale web application deployments, but it is a must have for testing and also for us to be able to claim that we take the secure by default design principle seriously. Signed-off-by: Peter Somogyvari --- package.json | 1 + .../cactus-cmd-api-server/package-lock.json | 53 ++++ packages/cactus-cmd-api-server/package.json | 4 + .../src/main/typescript/api-server.ts | 213 ++++++++++++---- .../main/typescript/config/config-service.ts | 230 ++++++++++++++++-- .../config/self-signed-pki-generator.ts | 159 ++++++++++++ .../src/main/typescript/public-api.ts | 8 + .../certificates-work-for-mutual-tls-test.ts | 142 +++++++++++ .../generates-working-certificates-test.ts | 102 ++++++++ .../deploy-contract-via-web-service.ts | 9 +- ...security-isolation-via-api-server-ports.ts | 1 + 11 files changed, 844 insertions(+), 78 deletions(-) create mode 100644 packages/cactus-cmd-api-server/src/main/typescript/config/self-signed-pki-generator.ts create mode 100644 packages/cactus-cmd-api-server/src/test/typescript/unit/config/self-signed-certificate-generator/certificates-work-for-mutual-tls-test.ts create mode 100644 packages/cactus-cmd-api-server/src/test/typescript/unit/config/self-signed-certificate-generator/generates-working-certificates-test.ts diff --git a/package.json b/package.json index 1457c183cc..0a5b077f81 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "@hyperledger-labs/cactus", "private": true, "scripts": { + "run-ci": "./tools/ci.sh", "configure": "lerna clean --yes && lerna bootstrap && npm-run-all build generate-api-server-config", "generate-api-server-config": "node ./tools/generate-api-server-config.js", "start:api-server": "node ./packages/cactus-cmd-api-server/dist/lib/main/typescript/cmd/cactus-api.js --config-file=.config.json", diff --git a/packages/cactus-cmd-api-server/package-lock.json b/packages/cactus-cmd-api-server/package-lock.json index eac2ae8f41..53582ece5e 100644 --- a/packages/cactus-cmd-api-server/package-lock.json +++ b/packages/cactus-cmd-api-server/package-lock.json @@ -77,6 +77,15 @@ "@types/serve-static": "*" } }, + "@types/express-http-proxy": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@types/express-http-proxy/-/express-http-proxy-1.6.1.tgz", + "integrity": "sha512-FjuKVtGaT3ccHD7uFr7vKDsn3shEEc/Upo2YnVsTfoDPuUbCV/GIsinG7gbrkzcIYELqh+8hYmn/rEfqMQA/9g==", + "dev": true, + "requires": { + "@types/express": "*" + } + }, "@types/express-serve-static-core": { "version": "4.17.7", "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.7.tgz", @@ -115,6 +124,15 @@ "integrity": "sha512-E6M6N0blf/jiZx8Q3nb0vNaswQeEyn0XlupO+xN6DtJ6r6IT4nXrTry7zhIfYvFCl3/8Cu6WIysmUBKiqV0bqQ==", "dev": true }, + "@types/node-forge": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@types/node-forge/-/node-forge-0.9.3.tgz", + "integrity": "sha512-2ARlg50tba1Ps3Jg/D416LEWo9TxVACfuZLNy8GvLiggndLxxfUBz8OyeZZsE9JIF6r8AOJrcaKS3O/5NVhQlA==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/qs": { "version": "6.9.2", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.2.tgz", @@ -475,6 +493,11 @@ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", "integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=" }, + "es6-promise": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz", + "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==" + }, "escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -527,6 +550,31 @@ "vary": "~1.1.2" } }, + "express-http-proxy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/express-http-proxy/-/express-http-proxy-1.6.0.tgz", + "integrity": "sha512-7Re6Lepg96NA2wiv7DC5csChAScn4K76/UgYnC71XiITCT1cgGTJUGK6GS0pIixudg3Fbx3Q6mmEW3mZv5tHFQ==", + "requires": { + "debug": "^3.0.1", + "es6-promise": "^4.1.1", + "raw-body": "^2.3.0" + }, + "dependencies": { + "debug": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz", + "integrity": "sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==", + "requires": { + "ms": "^2.1.1" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + } + } + }, "express-openapi-validator": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/express-openapi-validator/-/express-openapi-validator-3.10.0.tgz", @@ -828,6 +876,11 @@ "fetch-blob": "^1.0.5" } }, + "node-forge": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-0.9.1.tgz", + "integrity": "sha512-G6RlQt5Sb4GMBzXvhfkeFmbqR6MzhtnT7VTHuLadjkii3rdYHNdw0m8zA4BTxVIh68FicCQ2NSUANpsqkr9jvQ==" + }, "node-gyp-build": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.2.1.tgz", diff --git a/packages/cactus-cmd-api-server/package.json b/packages/cactus-cmd-api-server/package.json index 9a4cf5a805..3f8da66987 100755 --- a/packages/cactus-cmd-api-server/package.json +++ b/packages/cactus-cmd-api-server/package.json @@ -74,10 +74,12 @@ "convict-format-with-validator": "6.0.0", "cors": "2.8.5", "express": "4.17.1", + "express-http-proxy": "1.6.0", "express-openapi-validator": "3.10.0", "joi": "14.3.1", "js-sha3": "0.8.0", "node-fetch": "3.0.0-beta.4", + "node-forge": "0.9.1", "secp256k1": "4.0.0", "semver": "7.3.2", "sha3": "2.1.2", @@ -89,8 +91,10 @@ "@types/convict": "5.2.1", "@types/cors": "2.8.6", "@types/express": "4.17.6", + "@types/express-http-proxy": "1.6.1", "@types/joi": "14.3.4", "@types/multer": "1.4.2", + "@types/node-forge": "0.9.3", "@types/secp256k1": "3.5.3", "@types/semver": "7.3.1", "@types/uuid": "7.0.2" diff --git a/packages/cactus-cmd-api-server/src/main/typescript/api-server.ts b/packages/cactus-cmd-api-server/src/main/typescript/api-server.ts index bc3bea5a76..72b1dd89de 100644 --- a/packages/cactus-cmd-api-server/src/main/typescript/api-server.ts +++ b/packages/cactus-cmd-api-server/src/main/typescript/api-server.ts @@ -1,6 +1,11 @@ import path from "path"; -import { Server } from "http"; import { gte } from "semver"; +import { AddressInfo } from "net"; +import tls from "tls"; +import { Server, createServer } from "http"; +import { Server as SecureServer } from "https"; +import { createServer as createSecureServer } from "https"; +import expressHttpProxy from "express-http-proxy"; import express, { Express, Request, @@ -29,14 +34,16 @@ import { Servers } from "./common/servers"; export interface IApiServerConstructorOptions { pluginRegistry?: PluginRegistry; + httpServerApi?: Server | SecureServer; + httpServerCockpit?: Server | SecureServer; config: ICactusApiServerConfig; } export class ApiServer { private readonly log: Logger; private pluginRegistry: PluginRegistry | undefined; - private httpServerApi: Server | null = null; - private httpServerCockpit: Server | null = null; + private readonly httpServerApi: Server | SecureServer; + private readonly httpServerCockpit: Server | SecureServer; constructor(public readonly options: IApiServerConstructorOptions) { if (!options) { @@ -46,21 +53,71 @@ export class ApiServer { throw new Error(`ApiServer#ctor options.config was falsy`); } + LoggerProvider.setLogLevel(options.config.logLevel); + + if (this.options.httpServerApi) { + this.httpServerApi = this.options.httpServerApi; + } else if (this.options.config.apiTlsEnabled) { + this.httpServerApi = createSecureServer({ + key: this.options.config.apiTlsKeyPem, + cert: this.options.config.apiTlsCertPem, + }); + } else { + this.httpServerApi = createServer(); + } + + if (this.options.httpServerCockpit) { + this.httpServerCockpit = this.options.httpServerCockpit; + } else if (this.options.config.cockpitTlsEnabled) { + this.httpServerCockpit = createSecureServer({ + key: this.options.config.cockpitTlsKeyPem, + cert: this.options.config.cockpitTlsCertPem, + }); + } else { + this.httpServerCockpit = createServer(); + } + this.log = LoggerProvider.getOrCreate({ label: "api-server", level: options.config.logLevel, }); } - async start(): Promise { + async start(): Promise { this.checkNodeVersion(); + const tlsMaxVersion = this.options.config.tlsDefaultMaxVersion; + this.log.info("Setting tls.DEFAULT_MAX_VERSION to %s...", tlsMaxVersion); + tls.DEFAULT_MAX_VERSION = tlsMaxVersion; + try { - await this.startCockpitFileServer(); - await this.startApiServer(); + const { cockpitTlsEnabled, apiTlsEnabled } = this.options.config; + const addressInfoCockpit = await this.startCockpitFileServer(); + const addressInfoApi = await this.startApiServer(); + + { + const { apiHost: host } = this.options.config; + const { port } = addressInfoApi; + const protocol = apiTlsEnabled ? "https:" : "http:"; + const httpUrl = `${protocol}//${host}:${port}`; + this.log.info(`Cactus API reachable ${httpUrl}`); + } + + { + const { cockpitHost: host } = this.options.config; + const { port } = addressInfoCockpit; + const protocol = cockpitTlsEnabled ? "https:" : "http:"; + const httpUrl = `${protocol}//${host}:${port}`; + this.log.info(`Cactus Cockpit reachable ${httpUrl}`); + } + + return { addressInfoCockpit, addressInfoApi }; } catch (ex) { - this.log.error(`Failed to start ApiServer: ${ex.stack}`); + const errorMessage = `Failed to start ApiServer: ${ex.stack}`; + this.log.error(errorMessage); this.log.error(`Attempting shutdown...`); await this.shutdown(); + this.log.info(`Server shut down OK`); + throw new Error(errorMessage); } } @@ -83,11 +140,11 @@ export class ApiServer { } } - public getHttpServerApi(): Server | null { + public getHttpServerApi(): Server | SecureServer { return this.httpServerApi; } - public getHttpServerCockpit(): Server | null { + public getHttpServerCockpit(): Server | SecureServer { return this.httpServerCockpit; } @@ -106,12 +163,12 @@ export class ApiServer { public async initPluginRegistry(): Promise { const registry = new PluginRegistry({ plugins: [] }); - + const { logLevel } = this.options.config; this.log.info(`Instantiated empty registry, invoking plugin factories...`); for (const pluginImport of this.options.config.plugins) { const { packageName, options } = pluginImport; this.log.info(`Creating plugin from package: ${packageName}`, options); - const pluginOptions = { ...options, pluginRegistry: registry }; + const pluginOptions = { ...options, logLevel, pluginRegistry: registry }; const { createPluginFactory } = await import(packageName); const pluginFactory: PluginFactory< ICactusPlugin, @@ -126,7 +183,9 @@ export class ApiServer { public async shutdown(): Promise { this.log.info(`Shutting down API server ...`); + const registry = await this.getOrInitPluginRegistry(); + const webServicesShutdown = registry .getPlugins() .filter((pluginInstance) => isIPluginWebService(pluginInstance)) @@ -151,7 +210,7 @@ export class ApiServer { } } - async startCockpitFileServer(): Promise { + async startCockpitFileServer(): Promise { const cockpitWwwRoot = this.options.config.cockpitWwwRoot; this.log.info(`wwwRoot: ${cockpitWwwRoot}`); @@ -161,29 +220,70 @@ export class ApiServer { const resolvedIndexHtml = path.resolve(resolvedWwwRoot + "/index.html"); this.log.info(`resolvedIndexHtml: ${resolvedIndexHtml}`); + const cockpitCorsDomainCsv = this.options.config.cockpitCorsDomainCsv; + const allowedDomains = cockpitCorsDomainCsv.split(","); + const corsMiddleware = this.createCorsMiddleware(allowedDomains); + + const { + apiHost, + apiPort, + cockpitApiProxyRejectUnauthorized: rejectUnauthorized, + } = this.options.config; + const protocol = this.options.config.apiTlsEnabled ? "https:" : "http:"; + const apiHttpUrl = `${protocol}//${apiHost}:${apiPort}`; + + const apiProxyMiddleware = expressHttpProxy(apiHttpUrl, { + // preserve the path whatever it was. Without this the proxy just uses / + proxyReqPathResolver: (srcReq) => srcReq.originalUrl, + + proxyReqOptDecorator: (proxyReqOpts, srcReq) => { + const { originalUrl: thePath } = srcReq; + const srcHost = srcReq.header("host"); + const { host: destHostname, port: destPort } = proxyReqOpts; + const destHost = `${destHostname}:${destPort}`; + this.log.debug(`PROXY ${srcHost} => ${destHost} :: ${thePath}`); + + // make sure self signed certs are accepted if it was configured as such by the user + (proxyReqOpts as any).rejectUnauthorized = rejectUnauthorized; + return proxyReqOpts; + }, + }); + const app: Express = express(); + app.use("/api/v*", apiProxyMiddleware); app.use(compression()); + app.use(corsMiddleware); app.use(express.static(resolvedWwwRoot)); app.get("/*", (_, res) => res.sendFile(resolvedIndexHtml)); const cockpitPort: number = this.options.config.cockpitPort; const cockpitHost: string = this.options.config.cockpitHost; - await new Promise((resolve, reject) => { - this.httpServerCockpit = app.listen(cockpitPort, cockpitHost, () => { - const httpUrl = `http://${cockpitHost}:${cockpitPort}`; - this.log.info(`Cactus Cockpit UI reachable ${httpUrl}`); - resolve({ cockpitPort }); + if (!this.httpServerCockpit.listening) { + await new Promise((resolve, reject) => { + this.httpServerCockpit.once("error", reject); + this.httpServerCockpit.once("listening", resolve); + this.httpServerCockpit.listen(cockpitPort, cockpitHost); }); - this.httpServerCockpit.on("error", (err: any) => reject(err)); - }); + } + this.httpServerCockpit.on("request", app); + + // the address() method returns a string for unix domain sockets and null + // if the server is not listening but we don't car about any of those cases + // so the casting here should be safe. Famous last words... I know. + const addressInfo = this.httpServerCockpit.address() as AddressInfo; + this.log.info(`Cactus Cockpit net.AddressInfo`, addressInfo); + + return addressInfo; } - async startApiServer(): Promise { + async startApiServer(): Promise { const app: Application = express(); app.use(compression()); - const corsMiddleware = this.createCorsMiddleware(); + const apiCorsDomainCsv = this.options.config.apiCorsDomainCsv; + const allowedDomains = apiCorsDomainCsv.split(","); + const corsMiddleware = this.createCorsMiddleware(allowedDomains); app.use(corsMiddleware); app.use(bodyParser.json({ limit: "50mb" })); @@ -211,25 +311,31 @@ export class ApiServer { return (pluginInstance as IPluginWebService).installWebServices(app); }); - await Promise.all(webServicesInstalled); - this.log.info(`Installed ${webServicesInstalled.length} web services OK`); + const endpoints2D = await Promise.all(webServicesInstalled); + this.log.info(`Installed ${webServicesInstalled.length} web service(s) OK`); + + const endpoints = endpoints2D.reduce((acc, val) => acc.concat(val), []); + endpoints.forEach((ep) => this.log.info(`Endpoint={path=${ep.getPath()}}`)); const apiPort: number = this.options.config.apiPort; const apiHost: string = this.options.config.apiHost; - this.log.info(`Binding Cactus API to port ${apiPort}...`); - await new Promise((resolve, reject) => { - const httpServerApi = app.listen(apiPort, apiHost, () => { - const address: any = httpServerApi.address(); - this.log.info(`Successfully bound API to port ${apiPort}`, { address }); - if (address && address.port) { - resolve({ port: address.port }); - } else { - resolve({ port: apiPort }); - } + + if (!this.httpServerApi.listening) { + await new Promise((resolve, reject) => { + this.httpServerApi.once("error", reject); + this.httpServerApi.once("listening", resolve); + this.httpServerApi.listen(apiPort, apiHost); }); - this.httpServerApi = httpServerApi; - this.httpServerApi.on("error", (err) => reject(err)); - }); + } + this.httpServerApi.on("request", app); + + // the address() method returns a string for unix domain sockets and null + // if the server is not listening but we don't car about any of those cases + // so the casting here should be safe. Famous last words... I know. + const addressInfo = this.httpServerApi.address() as AddressInfo; + this.log.info(`Cactus API net.AddressInfo`, addressInfo); + + return addressInfo; } createOpenApiValidator(): OpenApiValidator { @@ -240,23 +346,24 @@ export class ApiServer { }); } - createCorsMiddleware(): RequestHandler { - const apiCorsDomainCsv = this.options.config.apiCorsDomainCsv; - const allowedDomains = apiCorsDomainCsv.split(","); - const allDomainsAllowed = allowedDomains.includes("*"); - - const corsOptions: CorsOptions = { - origin: (origin: string | undefined, callback) => { - if ( - allDomainsAllowed || - (origin && allowedDomains.indexOf(origin) !== -1) - ) { - callback(null, true); - } else { - callback(new Error(`CORS not allowed for Origin "${origin}".`)); - } - }, + createCorsMiddleware(allowedDomains: string[]): RequestHandler { + const allDomainsOk = allowedDomains.includes("*"); + + const corsOptionsDelegate = (req: Request, callback: any) => { + const origin = req.header("Origin"); + const isDomainOk = origin && allowedDomains.includes(origin); + // this.log.debug("CORS %j %j %s", allDomainsOk, isDomainOk, req.originalUrl); + + let corsOptions; + if (allDomainsOk) { + corsOptions = { origin: "*" }; // reflect (enable) the all origins in the CORS response + } else if (isDomainOk) { + corsOptions = { origin }; // reflect (enable) the requested origin in the CORS response + } else { + corsOptions = { origin: false }; // disable CORS for this request + } + callback(null, corsOptions); // callback expects two parameters: error and options }; - return cors(corsOptions); + return cors(corsOptionsDelegate); } } diff --git a/packages/cactus-cmd-api-server/src/main/typescript/config/config-service.ts b/packages/cactus-cmd-api-server/src/main/typescript/config/config-service.ts index 15d3cbf625..3a77fffadf 100644 --- a/packages/cactus-cmd-api-server/src/main/typescript/config/config-service.ts +++ b/packages/cactus-cmd-api-server/src/main/typescript/config/config-service.ts @@ -1,4 +1,6 @@ import { randomBytes } from "crypto"; +import { SecureVersion } from "tls"; +import { existsSync, readFileSync } from "fs"; import convict, { Schema, Config, SchemaObj } from "convict"; import { ipaddress } from "convict-format-with-validator"; import secp256k1 from "secp256k1"; @@ -9,6 +11,7 @@ import { LogLevelDesc, } from "@hyperledger/cactus-common"; import { FORMAT_PLUGIN_ARRAY } from "./convict-plugin-array-format"; +import { SelfSignedPkiGenerator, IPki } from "./self-signed-pki-generator"; convict.addFormat(FORMAT_PLUGIN_ARRAY); convict.addFormat(ipaddress); @@ -22,12 +25,25 @@ export interface ICactusApiServerOptions { configFile: string; cactusNodeId: string; logLevel: LogLevelDesc; + tlsDefaultMaxVersion: SecureVersion; cockpitHost: string; cockpitPort: number; + cockpitCorsDomainCsv: string; + cockpitApiProxyRejectUnauthorized: boolean; + cockpitTlsEnabled: boolean; + cockpitMtlsEnabled: boolean; cockpitWwwRoot: string; + cockpitTlsCertPem: string; + cockpitTlsKeyPem: string; + cockpitTlsClientCaPem: string; apiHost: string; apiPort: number; apiCorsDomainCsv: string; + apiTlsEnabled: boolean; + apiMtlsEnabled: boolean; + apiTlsCertPem: string; + apiTlsKeyPem: string; + apiTlsClientCaPem: string; plugins: IPluginImport[]; publicKey: string; privateKey: string; @@ -119,6 +135,23 @@ export class ConfigService { env: "MIN_NODE_VERSION", arg: "min-node-version", }, + tlsDefaultMaxVersion: { + doc: + "Sets the DEFAULT_MAX_VERSION property of the built-in tls module of NodeJS. " + + "Only makes a difference on NOdeJS 10 and older where TLS v1.3 is turned off by default. " + + "Newer NodeJS versions ship with TLS v1.3 enabled.", + format: (version: string) => { + ConfigService.formatNonBlankString(version); + const versions = ["TLSv1.3", "TLSv1.2", "TLSv1.1", "TLSv1"]; + if (!versions.includes(version)) { + const msg = `OK TLS versions ${versions.join(",")} Got: ${version}`; + throw new Error(msg); + } + }, + default: "TLSv1.3", + env: "TLS_DEFAULT_MAX_VERSION", + arg: "tls-default-max-version", + }, cockpitHost: { doc: "The host to bind the Cockpit webserver to. Secure default is: 127.0.0.1. Use 0.0.0.0 to bind for any host.", @@ -143,6 +176,70 @@ export class ConfigService { default: "packages/cactus-cmd-api-server/node_modules/@hyperledger/cactus-cockpit/www/", }, + cockpitCorsDomainCsv: { + doc: + "The Comma seperated list of domains to allow Cross Origin Resource Sharing from when " + + "serving static file requests. The wildcard (*) character is supported to allow CORS for any and all domains", + format: "*", + env: "COCKPIT_CORS_DOMAIN_CSV", + arg: "cockpit-cors-domain-csv", + default: "", + }, + cockpitTlsEnabled: { + doc: + "Enable TLS termination on the server. Useful if you do not have/want to " + + "have a reverse proxy or load balancer doing the SSL/TLS termination in your environment.", + format: Boolean, + env: "COCKPIT_TLS_ENABLED", + arg: "cockpit-tls-enabled", + default: true, + }, + cockpitApiProxyRejectUnauthorized: { + doc: + "When false: accept self signed certificates while proxying from the cockpit host " + + "to the API host. Acceptable for development environments, never use it in production.", + format: Boolean, + env: "COCKPIT_API_PROXY_REJECT_UNAUTHORIZED", + arg: "cockpit-api-proxy-reject-unauthorized", + default: true, + }, + cockpitMtlsEnabled: { + doc: + "Enable mTLS so that only clients presenting valid TLS certificate of " + + "their own will be able to connect to the cockpit", + format: Boolean, + env: "COCKPIT_MTLS_ENABLED", + arg: "cockpit-mtls-enabled", + default: true, + }, + cockpitTlsCertPem: { + doc: + "Either the file path to the cert file or the contents of it. Value is checked for existence on the file " + + "system as a path. If the latter comes back negative then value is assumed to be the actual pem string.", + format: ConfigService.filePathOrFileContents, + env: "COCKPIT_TLS_CERT_PEM", + arg: "cockpit-tls-cert-pem", + default: null as any, + }, + cockpitTlsKeyPem: { + sensitive: true, + doc: + "Either the file path to the key file or the contents of it. Value is checked for existence on the file " + + "system as a path. If the latter comes back negative then value is assumed to be the actual pem string.", + format: ConfigService.filePathOrFileContents, + env: "COCKPIT_TLS_KEY_PEM", + arg: "cockpit-tls-key-pem", + default: null as any, + }, + cockpitTlsClientCaPem: { + doc: + "Either the client cert file pat or the contents of it. Value is checked for existence on the file " + + "system as a path. If the latter comes back negative then value is assumed to be the actual pem string.", + format: ConfigService.filePathOrFileContents, + env: "COCKPIT_TLS_CLIENT_CA_PEM", + arg: "cockpit-tls-client-ca-pem", + default: null as any, + }, apiHost: { doc: "The host to bind the API to. Secure default is: 127.0.0.1. Use 0.0.0.0 to bind for any host.", @@ -168,6 +265,52 @@ export class ConfigService { arg: "api-cors-domain-csv", default: "", }, + apiTlsEnabled: { + doc: + "Enable TLS termination on the server. Useful if you do not have/want to " + + "have a reverse proxy or load balancer doing the SSL/TLS termination in your environment.", + format: Boolean, + env: "API_TLS_ENABLED", + arg: "api-tls-enabled", + default: true, + }, + apiMtlsEnabled: { + doc: + "Enable mTLS so that only clients presenting valid TLS certificate of " + + "their own will be able to connect to the web APIs", + format: Boolean, + env: "API_MTLS_ENABLED", + arg: "api-mtls-enabled", + default: true, + }, + apiTlsCertPem: { + doc: + "Either the file path to the cert file or the contents of it. Value is checked for existence on the file " + + "system as a path. If the latter comes back negative then value is assumed to be the actual pem string.", + format: ConfigService.filePathOrFileContents, + env: "API_TLS_CERT_PEM", + arg: "api-tls-cert-pem", + default: null as any, + }, + apiTlsClientCaPem: { + doc: + "Either the client cert file pat or the contents of it. Value is checked for existence on the file " + + "system as a path. If the latter comes back negative then value is assumed to be the actual pem string.", + format: ConfigService.filePathOrFileContents, + env: "API_TLS_CLIENT_CA_PEM", + arg: "api-tls-client-ca-pem", + default: null as any, + }, + apiTlsKeyPem: { + sensitive: true, + doc: + "Either the file path to the key file or the contents of it. Value is checked for existence on the file " + + "system as a path. If the latter comes back negative then value is assumed to be the actual pem string.", + format: ConfigService.filePathOrFileContents, + env: "API_TLS_KEY_PEM", + arg: "api-tls-key-pem", + default: null as any, + }, publicKey: { doc: "Public key of this Cactus node (the API server)", env: "PUBLIC_KEY", @@ -210,6 +353,15 @@ export class ConfigService { } } + private static filePathOrFileContents(value: string) { + ConfigService.formatNonBlankString(value); + if (existsSync(value)) { + return readFileSync(value); + } else { + return value; + } + } + /** * Remaps the example config returned by `newExampleConfig()` into a similar object whose keys are the designated * environment variable names. As an example it returns something like this: @@ -264,39 +416,73 @@ export class ConfigService { const privateKey = Buffer.from(privateKeyBytes).toString("hex"); const publicKey = Buffer.from(publicKeyBytes).toString("hex"); - return { - plugins: [ - { - packageName: "@hyperledger/cactus-plugin-kv-storage-memory", - options: {}, - }, - { - packageName: "@hyperledger/cactus-plugin-keychain-memory", - options: {}, - }, - { - packageName: "@hyperledger/cactus-plugin-web-service-consortium", - options: { - privateKey: "some-fake-key", - }, + const apiTlsEnabled: boolean = (schema.apiTlsEnabled as SchemaObj).default; + const apiHost = (schema.apiHost as SchemaObj).default; + const apiPort = (schema.apiPort as SchemaObj).default; + + // const apiProtocol = apiTlsEnabled ? "https:" : "http"; + // const apiBaseUrl = `${apiProtocol}//${apiHost}:${apiPort}`; + + const cockpitTlsEnabled: boolean = (schema.cockpitTlsEnabled as SchemaObj) + .default; + const cockpitHost = (schema.cockpitHost as SchemaObj).default; + const cockpitPort = (schema.cockpitPort as SchemaObj).default; + + // const cockpitProtocol = cockpitTlsEnabled ? "https:" : "http"; + // const cockpitBaseUrl = `${cockpitProtocol}//${cockpitHost}:${cockpitPort}`; + + const pkiGenerator = new SelfSignedPkiGenerator(); + const pkiServer: IPki = pkiGenerator.create("localhost"); + // const pkiClient: IPki = pkiGenerator.create("localhost", pkiServer); + + const plugins = [ + { + packageName: "@hyperledger/cactus-plugin-kv-storage-memory", + options: {}, + }, + { + packageName: "@hyperledger/cactus-plugin-keychain-memory", + options: {}, + }, + { + packageName: "@hyperledger/cactus-plugin-web-service-consortium", + options: { + privateKey, }, - ], + }, + ]; + + return { configFile: ".config.json", cactusNodeId: uuidV4(), logLevel: "debug", minNodeVersion: (schema.minNodeVersion as SchemaObj).default, - publicKey, - privateKey, + tlsDefaultMaxVersion: "TLSv1.3", + apiHost, + apiPort, apiCorsDomainCsv: (schema.apiCorsDomainCsv as SchemaObj).default, - apiHost: (schema.apiHost as SchemaObj).default, - apiPort: (schema.apiPort as SchemaObj).default, - cockpitHost: (schema.cockpitHost as SchemaObj).default, - cockpitPort: (schema.cockpitPort as SchemaObj).default, + apiMtlsEnabled: false, + cockpitApiProxyRejectUnauthorized: true, + apiTlsEnabled, + apiTlsCertPem: pkiServer.certificatePem, + apiTlsKeyPem: pkiServer.privateKeyPem, + apiTlsClientCaPem: "-", // API mTLS is off so this will not crash the server + cockpitHost, + cockpitPort, cockpitWwwRoot: (schema.cockpitWwwRoot as SchemaObj).default, + cockpitCorsDomainCsv: (schema.cockpitCorsDomainCsv as SchemaObj).default, + cockpitMtlsEnabled: false, + cockpitTlsEnabled, + cockpitTlsCertPem: pkiServer.certificatePem, + cockpitTlsKeyPem: pkiServer.privateKeyPem, + cockpitTlsClientCaPem: "-", // Cockpit mTLS is off so this will not crash the server + publicKey, + privateKey, keychainSuffixPublicKey: (schema.keychainSuffixPublicKey as SchemaObj) .default, keychainSuffixPrivateKey: (schema.keychainSuffixPrivateKey as SchemaObj) .default, + plugins, }; } diff --git a/packages/cactus-cmd-api-server/src/main/typescript/config/self-signed-pki-generator.ts b/packages/cactus-cmd-api-server/src/main/typescript/config/self-signed-pki-generator.ts new file mode 100644 index 0000000000..eaa8f63ab5 --- /dev/null +++ b/packages/cactus-cmd-api-server/src/main/typescript/config/self-signed-pki-generator.ts @@ -0,0 +1,159 @@ +import { pki, md } from "node-forge"; +import { v4 as uuidV4 } from "uuid"; +import { Strings } from "@hyperledger/cactus-common"; + +export type ForgeKeyPair = pki.rsa.KeyPair; +export type ForgePrivateKey = pki.rsa.PrivateKey; +export type ForgeCertificate = pki.Certificate; +export type ForgeCertificateField = pki.CertificateField; + +/** + * PKI as in public key infrastructure and x509 certificates. + */ +export interface IPki { + keyPair: ForgeKeyPair; + certificate: ForgeCertificate; + certificatePem: string; + privateKeyPem: string; +} + +/** + * Do not use this for anything in a production deployment. It's meant as a helper + * class for development and testing purposes (enhancing developer experience). + * + * Secure by default is one of our core design principles and it's much harder to + * enforce/implement that it sounds when you also do not want to ruin the ease + * of use of the software. Dynamically pre-provisioning PKI is notoriously + * complicated and error prone to the average user/developer. + * + */ +export class SelfSignedPkiGenerator { + public create(commonName: string, parent?: IPki): IPki { + const keyPair: pki.rsa.KeyPair = pki.rsa.generateKeyPair(4096); + const privateKeyPem: string = pki.privateKeyToPem(keyPair.privateKey); + const certificate = pki.createCertificate(); + + this.configureCertificateParameters(keyPair, certificate, commonName); + if (parent) { + certificate.setIssuer(parent.certificate.subject.attributes); + certificate.publicKey = keyPair.publicKey; + // certificate.privateKey = keyPair.privateKey; + certificate.sign(parent.keyPair.privateKey, md.sha512.create()); + + if (!parent.certificate.verify(certificate)) { + throw new Error("Could not verify newly generated certificate"); + } + } else { + certificate.sign(keyPair.privateKey, md.sha512.create()); + } + + const certificatePem = pki.certificateToPem(certificate); + return { keyPair, certificate, certificatePem, privateKeyPem }; + } + + public configureCertificateParameters( + keyPair: pki.rsa.KeyPair, + certificate: pki.Certificate, + commonName: string + ): pki.Certificate { + // 20 octets max for serial numbers of certs as per the standard + const serialNumber = Strings.replaceAll(uuidV4(), "-", "").substring(0, 19); + certificate.serialNumber = serialNumber; + certificate.publicKey = keyPair.publicKey; + certificate.privateKey = keyPair.privateKey; + certificate.validity.notBefore = new Date(); + certificate.validity.notAfter = new Date(); + + const nextYear = certificate.validity.notBefore.getFullYear() + 1; + certificate.validity.notAfter.setFullYear(nextYear); + + const certificateFields: ForgeCertificateField[] = [ + { + shortName: "CN", + name: "commonName", + value: commonName, + }, + { + name: "countryName", + value: "Universe", + }, + { + shortName: "ST", + value: "Milky Way", + }, + { + shortName: "L", + name: "localityName", + value: "Planet Earth", + }, + { + shortName: "O", + name: "organizationName", + value: "Hyperledger", + }, + { + shortName: "OU", + value: "Cactus", + }, + { + name: "unstructuredName", + value: "Cactus Dummy Self Signed Certificates", + }, + ]; + + certificate.setSubject(certificateFields); + + certificate.setIssuer(certificateFields); + + certificate.setExtensions([ + { + name: "basicConstraints", + cA: true, + }, + { + name: "keyUsage", + keyCertSign: true, + digitalSignature: true, + nonRepudiation: true, + keyEncipherment: true, + dataEncipherment: true, + }, + { + name: "extKeyUsage", + serverAuth: true, + clientAuth: true, + codeSigning: true, + emailProtection: true, + timeStamping: true, + }, + { + name: "nsCertType", + client: true, + server: true, + email: true, + objsign: true, + sslCA: true, + emailCA: true, + objCA: true, + }, + { + name: "subjectAltName", + altNames: [ + { + type: 6, // URI + value: "localhost", + }, + { + type: 7, // IP + ip: "127.0.0.1", + }, + ], + }, + { + name: "subjectKeyIdentifier", + }, + ]); + + return certificate; + } +} diff --git a/packages/cactus-cmd-api-server/src/main/typescript/public-api.ts b/packages/cactus-cmd-api-server/src/main/typescript/public-api.ts index 5b8641b08d..3f3b6f1051 100755 --- a/packages/cactus-cmd-api-server/src/main/typescript/public-api.ts +++ b/packages/cactus-cmd-api-server/src/main/typescript/public-api.ts @@ -3,3 +3,11 @@ export { ConfigService, ICactusApiServerOptions, } from "./config/config-service"; +export { + SelfSignedPkiGenerator, + ForgeCertificateField, + ForgeCertificate, + ForgeKeyPair, + ForgePrivateKey, + IPki, +} from "./config/self-signed-pki-generator"; diff --git a/packages/cactus-cmd-api-server/src/test/typescript/unit/config/self-signed-certificate-generator/certificates-work-for-mutual-tls-test.ts b/packages/cactus-cmd-api-server/src/test/typescript/unit/config/self-signed-certificate-generator/certificates-work-for-mutual-tls-test.ts new file mode 100644 index 0000000000..f4ea21cda6 --- /dev/null +++ b/packages/cactus-cmd-api-server/src/test/typescript/unit/config/self-signed-certificate-generator/certificates-work-for-mutual-tls-test.ts @@ -0,0 +1,142 @@ +// tslint:disable-next-line: no-var-requires +const tap = require("tap"); +import { AddressInfo } from "net"; +import { TLSSocket } from "tls"; +import { + Server, + createServer, + request, + RequestOptions, + ServerOptions, +} from "https"; +import { + SelfSignedPkiGenerator, + IPki, +} from "../../../../../main/typescript/public-api"; +import { Logger, LoggerProvider } from "@hyperledger/cactus-common"; + +const log: Logger = LoggerProvider.getOrCreate({ + label: "test-generates-working-certificates", + level: "TRACE", +}); + +tap.test("works with HTTPS NodeJS module", async (assert: any) => { + assert.ok(SelfSignedPkiGenerator, "class present on API surface"); + + const generator = new SelfSignedPkiGenerator(); + assert.ok(generator, "Instantiated SelfSignedCertificateGenerator OK."); + + const serverCert: IPki = generator.create("localhost"); + assert.ok(serverCert, "serverCert truthy"); + assert.ok(serverCert.certificatePem, "serverCert.certificatePem truthy"); + assert.ok(serverCert.privateKeyPem, "serverCert.privateKeyPem truthy"); + assert.ok(serverCert.certificate, "serverCert.certificate truthy"); + assert.ok(serverCert.keyPair, "serverCert.keyPair truthy"); + + // make sure the client cert has a different common name otherwise they collide and everything breaks in this test + const clientCert: IPki = generator.create("client.localhost", serverCert); + assert.ok(clientCert, "clientCert truthy"); + assert.ok(clientCert.certificatePem, "clientCert.certificatePem truthy"); + assert.ok(clientCert.privateKeyPem, "clientCert.privateKeyPem truthy"); + assert.ok(clientCert.certificate, "clientCert.certificate truthy"); + assert.ok(clientCert.keyPair, "clientCert.keyPair truthy"); + assert.ok( + serverCert.certificate.verify(clientCert.certificate), + "Server cert verified client cert OK" + ); + + const serverOptions: ServerOptions = { + key: serverCert.privateKeyPem, + cert: serverCert.certificatePem, + + ca: [serverCert.certificatePem], + + rejectUnauthorized: true, + requestCert: true, + }; + + const MESSAGE = "hello world\n"; + + const server: Server = await new Promise((resolve, reject) => { + const listener = (aRequest: any, aResponse: any) => { + aResponse.writeHead(200); + aResponse.end(MESSAGE); + }; + const aServer: Server = createServer(serverOptions, listener); + aServer.once("tlsClientError", (err: Error) => + log.error("tlsClientError: %j", err) + ); + aServer.on("keylog", (data: Buffer, tlsSocket: TLSSocket) => { + log.debug("keylog:tlsSocket.address(): %j", tlsSocket.address()); + log.debug("keylog:data: %j", data.toString("utf-8")); + }); + aServer.on("OCSPRequest", (...args: any[]) => + log.debug("OCSPRequest: %j", args) + ); + aServer.on("secureConnection", (tlsSocket: TLSSocket) => + log.debug("secureConnection: tlsSocket.address() %j", tlsSocket.address()) + ); + + aServer.once("listening", () => resolve(aServer)); + aServer.listen(0, "localhost"); + assert.tearDown(() => aServer.close()); + }); + + assert.ok(server, "HTTPS Server object truthy"); + assert.ok(server.listening, "HTTPS Server is indeed listening"); + + const addressInfo = server.address() as AddressInfo; + assert.ok(addressInfo, "HTTPS Server provided truthy AddressInfo"); + assert.ok(addressInfo.port, "HTTPS Server provided truthy AddressInfo.port"); + log.debug("AddressInfo for test HTTPS server: %j", addressInfo); + + const response = await new Promise((resolve, reject) => { + const requestOptions: RequestOptions = { + protocol: "https:", + host: addressInfo.address, + port: addressInfo.port, + path: "/", + method: "GET", + + // IMPORTANT: + // Without this self signed certs are rejected because they are not part of a chain with a trusted root CA + // By declaring our certificate here we tell the HTTPS client to assume that our certificate is a trusted one. + // This is fine for a test case because we don't want thid party dependencies on test execution. + ca: [serverCert.certificatePem], + rejectUnauthorized: true, + + // We present the server with the client's certificate to put the "mutual" in mTLS for real. + key: clientCert.privateKeyPem, + cert: clientCert.certificatePem, + }; + + const req = request(requestOptions, (res) => { + res.setEncoding("utf8"); + + let body = ""; + + res.on("data", (chunk) => { + body = body + chunk; + }); + + res.on("end", () => { + if (res.statusCode !== 200) { + reject(`HTTPS request failed. Status code: ${res.statusCode}`); + } else { + resolve(body); + } + }); + }); + + req.on("error", (error) => { + log.error("Failed to send request: ", error); + reject(error); + }); + req.end(); + }); + + assert.ok(response, "Server response truthy"); + assert.equal(response, MESSAGE, `Server responded with "${MESSAGE}"`); + + assert.end(); +}); diff --git a/packages/cactus-cmd-api-server/src/test/typescript/unit/config/self-signed-certificate-generator/generates-working-certificates-test.ts b/packages/cactus-cmd-api-server/src/test/typescript/unit/config/self-signed-certificate-generator/generates-working-certificates-test.ts new file mode 100644 index 0000000000..ae46b3e4e0 --- /dev/null +++ b/packages/cactus-cmd-api-server/src/test/typescript/unit/config/self-signed-certificate-generator/generates-working-certificates-test.ts @@ -0,0 +1,102 @@ +// tslint:disable-next-line: no-var-requires +const tap = require("tap"); +import { AddressInfo } from "net"; +import { Server, createServer, request, RequestOptions } from "https"; +import { + SelfSignedPkiGenerator, + IPki, +} from "../../../../../main/typescript/public-api"; +import { Logger, LoggerProvider } from "@hyperledger/cactus-common"; + +const log: Logger = LoggerProvider.getOrCreate({ + label: "test-generates-working-certificates", + level: "TRACE", +}); + +tap.test("works with HTTPS NodeJS module", async (assert: any) => { + assert.ok(SelfSignedPkiGenerator, "class present on API surface"); + + const generator = new SelfSignedPkiGenerator(); + assert.ok(generator, "Instantiated SelfSignedCertificateGenerator OK."); + const serverCertData: IPki = generator.create("localhost"); + assert.ok(serverCertData, "Returned cert data truthy"); + assert.ok(serverCertData.certificatePem, "certData.certificatePem truthy"); + assert.ok(serverCertData.privateKeyPem, "certData.privateKeyPem truthy"); + assert.ok(serverCertData.certificate, "certData.certificate truthy"); + assert.ok(serverCertData.keyPair, "certData.keyPair truthy"); + + const serverOptions = { + key: serverCertData.privateKeyPem, + cert: serverCertData.certificatePem, + }; + + const MESSAGE = "hello world\n"; + + const server: Server = await new Promise((resolve, reject) => { + const listener = (aRequest: any, aResponse: any) => { + aResponse.writeHead(200); + aResponse.end(MESSAGE); + }; + const aServer: Server = createServer(serverOptions, listener); + aServer.once("tlsClientError", (err: Error) => { + log.error("tlsClientError: %j", err); + reject(err); + }); + aServer.once("listening", () => resolve(aServer)); + aServer.listen(0, "localhost"); + assert.tearDown(() => aServer.close()); + }); + + assert.ok(server, "HTTPS Server object truthy"); + assert.ok(server.listening, "HTTPS Server is indeed listening"); + + const addressInfo = server.address() as AddressInfo; + assert.ok(addressInfo, "HTTPS Server provided truthy AddressInfo"); + assert.ok(addressInfo.port, "HTTPS Server provided truthy AddressInfo.port"); + log.debug("AddressInfo for test HTTPS server: %j", addressInfo); + + const response = await new Promise((resolve, reject) => { + const requestOptions: RequestOptions = { + protocol: "https:", + host: addressInfo.address, + port: addressInfo.port, + path: "/", + method: "GET", + + // IMPORTANT: + // Without this self signed certs are rejected because they are not part of a chain with a trusted root CA + // By declaring our certificate here we tell the HTTPS client to assume that our certificate is a trusted one. + // This is fine for a test case because we don't want thid party dependencies on test execution. + ca: serverCertData.certificatePem, + }; + + const req = request(requestOptions, (res) => { + res.setEncoding("utf8"); + + let body = ""; + + res.on("data", (chunk) => { + body = body + chunk; + }); + + res.on("end", () => { + if (res.statusCode !== 200) { + reject(`HTTPS request failed. Status code: ${res.statusCode}`); + } else { + resolve(body); + } + }); + }); + + req.on("error", (error) => { + log.error("Failed to send request: ", error); + reject(error); + }); + req.end(); + }); + + assert.ok(response, "Server response truthy"); + assert.equal(response, MESSAGE, `Server responded with "${MESSAGE}"`); + + assert.end(); +}); diff --git a/packages/cactus-test-plugin-ledger-connector-quorum/src/test/typescript/integration/plugin-ledger-connector-quorum/deploy-contract/deploy-contract-via-web-service.ts b/packages/cactus-test-plugin-ledger-connector-quorum/src/test/typescript/integration/plugin-ledger-connector-quorum/deploy-contract/deploy-contract-via-web-service.ts index 0c6d407b0f..28637a6948 100644 --- a/packages/cactus-test-plugin-ledger-connector-quorum/src/test/typescript/integration/plugin-ledger-connector-quorum/deploy-contract/deploy-contract-via-web-service.ts +++ b/packages/cactus-test-plugin-ledger-connector-quorum/src/test/typescript/integration/plugin-ledger-connector-quorum/deploy-contract/deploy-contract-via-web-service.ts @@ -44,6 +44,7 @@ tap.test( const cactusApiServerOptions: ICactusApiServerOptions = configService.newExampleConfig(); cactusApiServerOptions.configFile = ""; cactusApiServerOptions.apiCorsDomainCsv = "*"; + cactusApiServerOptions.apiTlsEnabled = false; cactusApiServerOptions.apiPort = 0; const config = configService.newExampleConfigConvict( cactusApiServerOptions @@ -87,9 +88,11 @@ tap.test( const httpServer = apiServer.getHttpServerApi(); const addressInfo: any = httpServer?.address(); log.debug(`AddressInfo: `, addressInfo); - const CACTUS_API_HOST = `http://${addressInfo.address}:${addressInfo.port}`; + const protocol = config.get("apiTlsEnabled") ? "https:" : "http:"; + const basePath = `${protocol}//${addressInfo.address}:${addressInfo.port}`; + log.debug(`SDK base path: %s`, basePath); - const configuration = new Configuration({ basePath: CACTUS_API_HOST }); + const configuration = new Configuration({ basePath }); const api = new DefaultApi(configuration); // 7. Issue an API call to the API server via the SDK verifying that the SDK and the API server both work @@ -107,7 +110,7 @@ tap.test( contractJsonArtifact: HelloWorldContractJson, }; const pluginId = ledgerConnectorQuorum.getId(); - const url = `${CACTUS_API_HOST}/api/v1/plugins/${pluginId}/contract/deploy`; + const url = `${basePath}/api/v1/plugins/${pluginId}/contract/deploy`; // 9. Deploy smart contract by issuing REST API call // TODO: Make this part of the SDK so that manual request assembly is not required. Should plugins have their own SDK? const response2 = await axios.post(url, bodyObject, {}); diff --git a/packages/cactus-test-plugin-web-service-consortium/src/test/typescript/integration/plugin-web-service-consortium/security-isolation-via-api-server-ports.ts b/packages/cactus-test-plugin-web-service-consortium/src/test/typescript/integration/plugin-web-service-consortium/security-isolation-via-api-server-ports.ts index cc13fa3425..0f14d90371 100644 --- a/packages/cactus-test-plugin-web-service-consortium/src/test/typescript/integration/plugin-web-service-consortium/security-isolation-via-api-server-ports.ts +++ b/packages/cactus-test-plugin-web-service-consortium/src/test/typescript/integration/plugin-web-service-consortium/security-isolation-via-api-server-ports.ts @@ -57,6 +57,7 @@ tap.test( cactusApiServerOptions.configFile = ""; cactusApiServerOptions.apiCorsDomainCsv = "*"; cactusApiServerOptions.apiPort = 0; + cactusApiServerOptions.apiTlsEnabled = false; const config = configService.newExampleConfigConvict( cactusApiServerOptions );