diff --git a/packages/backend-app-api/package.json b/packages/backend-app-api/package.json index 20e196c4b6..20c723bd51 100644 --- a/packages/backend-app-api/package.json +++ b/packages/backend-app-api/package.json @@ -48,12 +48,14 @@ "minimatch": "^5.0.0", "node-forge": "^1.3.1", "selfsigned": "^2.0.0", + "stoppable": "^1.1.0", "winston": "^3.2.1" }, "devDependencies": { "@backstage/cli": "workspace:^", "@types/fs-extra": "^9.0.3", - "@types/node-forge": "^1.3.0" + "@types/node-forge": "^1.3.0", + "@types/stoppable": "^1.1.0" }, "files": [ "dist", diff --git a/packages/backend-app-api/src/lib/http/createHttpServer.ts b/packages/backend-app-api/src/lib/http/createHttpServer.ts new file mode 100644 index 0000000000..f72269c079 --- /dev/null +++ b/packages/backend-app-api/src/lib/http/createHttpServer.ts @@ -0,0 +1,98 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as http from 'http'; +import * as https from 'https'; +import stoppableServer from 'stoppable'; +import { RequestListener } from 'http'; +import { LoggerService } from '@backstage/backend-plugin-api'; +import { HttpServerOptions, ExtendedHttpServer } from './types'; +import { getGeneratedCertificate } from './getGeneratedCertificate'; + +/** + * Creates a Node.js HTTP or HTTPS server instance. + * + * @public + */ +export async function createHttpServer( + listener: RequestListener, + options: HttpServerOptions, + deps: { logger: LoggerService }, +): Promise { + const server = await createServer(listener, options, deps); + + const stopper = stoppableServer(server, 0); + + return Object.assign(server, { + start() { + return new Promise((resolve, reject) => { + const handleStartupError = (error: Error) => { + server.close(); + reject(error); + }; + + server.on('error', handleStartupError); + + const { host, port } = options.listen; + server.listen(port, host, () => { + server.off('error', handleStartupError); + deps.logger.info(`Listening on ${host}:${port}`); + resolve(); + }); + }); + }, + + stop() { + return new Promise((resolve, reject) => { + stopper.stop((error?: Error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); + }, + + port() { + const address = server.address(); + if (typeof address === 'string' || address === null) { + throw new Error(`Unexpected server address '${address}'`); + } + return address.port; + }, + }); +} + +async function createServer( + listener: RequestListener, + options: HttpServerOptions, + deps: { logger: LoggerService }, +): Promise { + if (options.https) { + const { certificate } = options.https; + if (certificate.type === 'generated') { + const credentials = await getGeneratedCertificate( + certificate.hostname, + deps.logger, + ); + return https.createServer(credentials, listener); + } + return https.createServer(certificate, listener); + } + + return http.createServer(listener); +} diff --git a/packages/backend-app-api/src/services/implementations/rootHttpRouter/hostFactory.ts b/packages/backend-app-api/src/lib/http/getGeneratedCertificate.ts similarity index 55% rename from packages/backend-app-api/src/services/implementations/rootHttpRouter/hostFactory.ts rename to packages/backend-app-api/src/lib/http/getGeneratedCertificate.ts index db4343cae5..b5cd420aff 100644 --- a/packages/backend-app-api/src/services/implementations/rootHttpRouter/hostFactory.ts +++ b/packages/backend-app-api/src/lib/http/getGeneratedCertificate.ts @@ -16,86 +16,16 @@ import fs from 'fs-extra'; import { resolve as resolvePath, dirname } from 'path'; -import express from 'express'; -import * as http from 'http'; -import * as https from 'https'; import { LoggerService } from '@backstage/backend-plugin-api'; -import { HttpsSettings } from './config'; import forge from 'node-forge'; const FIVE_DAYS_IN_MS = 5 * 24 * 60 * 60 * 1000; const IP_HOSTNAME_REGEX = /:|^\d+\.\d+\.\d+\.\d+$/; -/** - * Creates a Http server instance based on an Express application. - * - * @param app - The Express application object - * @param logger - Optional Winston logger object - * @returns A Http server instance - * - */ -export function createHttpServer( - app: express.Express, - logger?: LoggerService, -): http.Server { - logger?.info('Initializing http server'); - - return http.createServer(app); -} - -/** - * Creates a Https server instance based on an Express application. - * - * @param app - The Express application object - * @param httpsSettings - HttpsSettings for self-signed certificate generation - * @param logger - Optional Winston logger object - * @returns A Https server instance - * - */ -export async function createHttpsServer( - app: express.Express, - httpsSettings: HttpsSettings, - logger?: LoggerService, -): Promise { - logger?.info('Initializing https server'); - - let credentials: { key: string | Buffer; cert: string | Buffer }; - - if ('hostname' in httpsSettings?.certificate) { - credentials = await getGeneratedCertificate( - httpsSettings.certificate.hostname, - logger, - ); - } else { - logger?.info('Loading certificate from config'); - - credentials = { - key: httpsSettings?.certificate?.key, - cert: httpsSettings?.certificate?.cert, - }; - } - - if (!credentials.key || !credentials.cert) { - throw new Error('Invalid HTTPS credentials'); - } - - return https.createServer(credentials, app) as http.Server; -} - -function getCertificateExpiration(cert: string, logger?: LoggerService) { - try { - const crt = forge.pki.certificateFromPem(cert); - return crt.validity.notAfter.getTime() - Date.now(); - } catch (error) { - logger?.warn(`Unable to parse self-signed certificate. ${error}`); - return 0; - } -} - -async function getGeneratedCertificate( +export async function getGeneratedCertificate( hostname: string, - logger?: LoggerService, + logger: LoggerService, ) { const hasModules = await fs.pathExists('node_modules'); let certPath; @@ -109,24 +39,30 @@ async function getGeneratedCertificate( } if (await fs.pathExists(certPath)) { - const cert = await fs.readFile(certPath); - const remainingMs = getCertificateExpiration(cert.toString(), logger); - if (remainingMs > FIVE_DAYS_IN_MS) { - logger?.info('Using existing self-signed certificate'); - return { - key: cert, - cert, - }; + try { + const cert = await fs.readFile(certPath); + + const crt = forge.pki.certificateFromPem(cert.toString()); + const remainingMs = crt.validity.notAfter.getTime() - Date.now(); + if (remainingMs > FIVE_DAYS_IN_MS) { + logger.info('Using existing self-signed certificate'); + return { + key: cert, + cert, + }; + } + } catch (error) { + logger.warn(`Unable to use existing self-signed certificate, ${error}`); } } - logger?.info('Generating new self-signed certificate'); - const newCert = await createCertificate(hostname); + logger.info('Generating new self-signed certificate'); + const newCert = await generateCertificate(hostname); await fs.writeFile(certPath, newCert.cert + newCert.key, 'utf8'); return newCert; } -async function createCertificate(hostname: string) { +async function generateCertificate(hostname: string) { const attributes = [ { name: 'commonName', diff --git a/packages/backend-app-api/src/lib/http/types.ts b/packages/backend-app-api/src/lib/http/types.ts index a33415ec83..ff96f69bff 100644 --- a/packages/backend-app-api/src/lib/http/types.ts +++ b/packages/backend-app-api/src/lib/http/types.ts @@ -14,6 +14,21 @@ * limitations under the License. */ +import * as http from 'http'; + +/** + * An HTTP server extended with utility methods. + * + * @public + */ +export interface ExtendedHttpServer extends http.Server { + start(): Promise; + + stop(): Promise; + + port(): number; +} + /** * Options for starting up an HTTP server. * diff --git a/yarn.lock b/yarn.lock index ff351c8957..53e68b28f8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3388,6 +3388,7 @@ __metadata: "@types/express": ^4.17.6 "@types/fs-extra": ^9.0.3 "@types/node-forge": ^1.3.0 + "@types/stoppable": ^1.1.0 cors: ^2.8.5 express: ^4.17.1 express-promise-router: ^4.1.0 @@ -3395,6 +3396,7 @@ __metadata: minimatch: ^5.0.0 node-forge: ^1.3.1 selfsigned: ^2.0.0 + stoppable: ^1.1.0 winston: ^3.2.1 languageName: unknown linkType: soft