backend-common: reimplement service builder using backend-app-api

Co-authored-by: blam <ben@blam.sh>
Co-authored-by: Johan Haals <johan.haals@gmail.com>
Co-authored-by: Fredrik Adelöw <freben@gmail.com>
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2023-01-09 17:23:53 +01:00
parent a70b64b488
commit 56d7066e85
@@ -18,10 +18,9 @@ import { Config } from '@backstage/config';
import compression from 'compression';
import cors from 'cors';
import express, { Router, ErrorRequestHandler } from 'express';
import helmet from 'helmet';
import helmet, { HelmetOptions } from 'helmet';
import { ContentSecurityPolicyOptions } from 'helmet/dist/types/middlewares/content-security-policy';
import * as http from 'http';
import stoppable from 'stoppable';
import { LoggerService } from '@backstage/backend-plugin-api';
import { useHotCleanup } from '../../hot';
import { getRootLogger } from '../../logging';
@@ -32,26 +31,20 @@ import {
} from '../../middleware';
import { RequestLoggingHandlerFactory, ServiceBuilder } from '../types';
import {
CspOptions,
HttpsSettings,
readBaseOptions,
readCorsOptions,
readCspOptions,
readHttpsSettings,
} from './config';
import { createHttpServer, createHttpsServer } from './hostFactory';
readHelmetOptions,
readHttpServerOptions,
HttpServerOptions,
createHttpServer,
} from '@backstage/backend-app-api';
export const DEFAULT_PORT = 7007;
// '' is express default, which listens to all interfaces
const DEFAULT_HOST = '';
export type CspOptions = Record<string, string[]>;
export class ServiceBuilderImpl implements ServiceBuilder {
private port: number | undefined;
private host: string | undefined;
private logger: LoggerService | undefined;
private corsOptions: cors.CorsOptions | undefined;
private cspOptions: Record<string, string[] | false> | undefined;
private httpsSettings: HttpsSettings | undefined;
private serverOptions: HttpServerOptions;
private helmetOptions: HelmetOptions;
private corsOptions: cors.CorsOptions;
private routers: [string, Router][];
private requestLoggingHandler: RequestLoggingHandlerFactory | undefined;
private errorHandler: ErrorRequestHandler | undefined;
@@ -64,50 +57,29 @@ export class ServiceBuilderImpl implements ServiceBuilder {
this.routers = [];
this.module = moduleRef;
this.useDefaultErrorHandler = true;
this.serverOptions = readHttpServerOptions();
this.corsOptions = readCorsOptions();
this.helmetOptions = readHelmetOptions();
}
loadConfig(config: Config): ServiceBuilder {
const backendConfig = config.getOptionalConfig('backend');
if (!backendConfig) {
return this;
}
const baseOptions = readBaseOptions(backendConfig);
if (baseOptions.listenPort) {
this.port =
typeof baseOptions.listenPort === 'string'
? parseInt(baseOptions.listenPort, 10)
: baseOptions.listenPort;
}
if (baseOptions.listenHost) {
this.host = baseOptions.listenHost;
}
const corsOptions = readCorsOptions(backendConfig);
if (corsOptions) {
this.corsOptions = corsOptions;
}
const cspOptions = readCspOptions(backendConfig);
if (cspOptions) {
this.cspOptions = cspOptions;
}
const httpsSettings = readHttpsSettings(backendConfig);
if (httpsSettings) {
this.httpsSettings = httpsSettings;
}
this.serverOptions = readHttpServerOptions(backendConfig);
this.corsOptions = readCorsOptions(backendConfig);
this.helmetOptions = readHelmetOptions(backendConfig);
return this;
}
setPort(port: number): ServiceBuilder {
this.port = port;
this.serverOptions.listen.port = port;
return this;
}
setHost(host: string): ServiceBuilder {
this.host = host;
this.serverOptions.listen.host = host;
return this;
}
@@ -116,8 +88,24 @@ export class ServiceBuilderImpl implements ServiceBuilder {
return this;
}
setHttpsSettings(settings: HttpsSettings): ServiceBuilder {
this.httpsSettings = settings;
setHttpsSettings(settings: {
certificate: { key: string; cert: string } | { hostname: string };
}): ServiceBuilder {
if ('hostname' in settings.certificate) {
this.serverOptions.https = {
certificate: {
...settings.certificate,
type: 'generated',
},
};
} else {
this.serverOptions.https = {
certificate: {
...settings.certificate,
type: 'plain',
},
};
}
return this;
}
@@ -127,7 +115,11 @@ export class ServiceBuilderImpl implements ServiceBuilder {
}
setCsp(options: CspOptions): ServiceBuilder {
this.cspOptions = options;
const csp = this.helmetOptions.contentSecurityPolicy;
this.helmetOptions.contentSecurityPolicy = {
...(typeof csp === 'object' ? csp : {}),
directives: applyCspDirectives(options),
};
return this;
}
@@ -155,13 +147,10 @@ export class ServiceBuilderImpl implements ServiceBuilder {
async start(): Promise<http.Server> {
const app = express();
const { port, host, logger, corsOptions, httpsSettings, helmetOptions } =
this.getOptions();
const logger = this.logger ?? getRootLogger();
app.use(helmet(helmetOptions));
if (corsOptions) {
app.use(cors(corsOptions));
}
app.use(helmet(this.helmetOptions));
app.use(cors(this.corsOptions));
app.use(compression());
app.use(
(this.requestLoggingHandler ?? defaultRequestLoggingHandler)(logger),
@@ -179,58 +168,23 @@ export class ServiceBuilderImpl implements ServiceBuilder {
app.use(defaultErrorHandler());
}
const server: http.Server = httpsSettings
? await createHttpsServer(app, httpsSettings, logger)
: createHttpServer(app, logger);
const stoppableServer = stoppable(server, 0);
const server = await createHttpServer(app, this.serverOptions, { logger });
useHotCleanup(this.module, () =>
stoppableServer.stop((e: any) => {
if (e) console.error(e);
server.stop().catch(error => {
console.error(error);
}),
);
return new Promise((resolve, reject) => {
function handleStartupError(e: unknown) {
server.close();
reject(e);
}
await server.start();
server.on('error', handleStartupError);
server.listen(port, host, () => {
server.off('error', handleStartupError);
logger.info(`Listening on ${host}:${port}`);
resolve(stoppableServer);
});
});
}
private getOptions() {
return {
port: this.port ?? DEFAULT_PORT,
host: this.host ?? DEFAULT_HOST,
logger: this.logger ?? getRootLogger(),
corsOptions: this.corsOptions,
httpsSettings: this.httpsSettings,
helmetOptions: {
contentSecurityPolicy: {
useDefaults: false,
directives: applyCspDirectives(this.cspOptions),
},
// These are all disabled in order to maintain backwards compatibility
// when bumping helmet v5. We can't enable these by default because
// there is no way for users to configure them.
// TODO(Rugvip): We should give control of this setup to consumers
crossOriginEmbedderPolicy: false,
crossOriginOpenerPolicy: false,
crossOriginResourcePolicy: false,
originAgentCluster: false,
},
};
return server;
}
}
// TODO(Rugvip): This is a duplicate of the same logic over in backend-app-api.
// It's needed as we don't want to export this helper from there, but need
// It to implement the setCsp method here.
export function applyCspDirectives(
directives: Record<string, string[] | false> | undefined,
): ContentSecurityPolicyOptions['directives'] {