backend-app-api: forklift sevrice factory config from backend-common

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2023-01-04 15:09:30 +01:00
parent b777869011
commit 7695c5a44c
5 changed files with 19 additions and 1 deletions
@@ -1,108 +0,0 @@
/*
* 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 { ConfigReader } from '@backstage/config';
import { readCorsOptions, readCspOptions } from './config';
describe('config', () => {
describe('readCspOptions', () => {
it('reads valid values', () => {
const config = new ConfigReader({ csp: { key: ['value'] } });
expect(readCspOptions(config)).toEqual(
expect.objectContaining({
key: ['value'],
}),
);
});
it('accepts false', () => {
const config = new ConfigReader({ csp: { key: false } });
expect(readCspOptions(config)).toEqual(
expect.objectContaining({
key: false,
}),
);
});
it('rejects invalid value types', () => {
const config = new ConfigReader({ csp: { key: [4] } });
expect(() => readCspOptions(config)).toThrow(/wanted string-array/);
});
});
describe('readCorsOptions', () => {
it('reads single string', () => {
const mockCallback = jest.fn();
const config = new ConfigReader({ cors: { origin: 'https://*.value*' } });
const cors = readCorsOptions(config);
expect(cors).toEqual(
expect.objectContaining({
origin: expect.any(Function),
}),
);
const origin = cors?.origin as Function;
origin('https://a.value', mockCallback); // valid origin
origin('http://a.value', mockCallback); // invalid origin
origin(undefined, mockCallback); // when not origin needs to reject the call
expect(mockCallback.mock.calls[0][0]).toBe(null);
expect(mockCallback.mock.calls[1][0]).toBe(null);
expect(mockCallback.mock.calls[0][1]).toBe(true);
expect(mockCallback.mock.calls[1][1]).toBe(false);
expect(mockCallback.mock.calls[2][1]).toBe(false);
});
it('reads string array', () => {
const mockCallback = jest.fn();
const config = new ConfigReader({
cors: {
origin: ['http?(s)://*.value?(-+([0-9])).com', 'http://*.value'],
},
});
const cors = readCorsOptions(config);
expect(cors).toEqual(
expect.objectContaining({
origin: expect.any(Function),
}),
);
const origin = cors?.origin as Function;
origin('https://a.b.c.value-9.com', mockCallback);
origin('http://a.value-999.com', mockCallback);
origin('http://a.value', mockCallback);
origin('http://a.valuex', mockCallback);
expect(mockCallback.mock.calls[0][0]).toBe(null);
expect(mockCallback.mock.calls[1][0]).toBe(null);
expect(mockCallback.mock.calls[2][0]).toBe(null);
expect(mockCallback.mock.calls[3][0]).toBe(null);
expect(mockCallback.mock.calls[0][1]).toBe(true);
expect(mockCallback.mock.calls[1][1]).toBe(true);
expect(mockCallback.mock.calls[2][1]).toBe(true);
expect(mockCallback.mock.calls[3][1]).toBe(false);
});
it('reads undefined origin', () => {
const config = new ConfigReader({
cors: {},
});
const cors = readCorsOptions(config);
expect(cors).toEqual(expect.objectContaining({}));
expect(cors?.origin).toBeUndefined();
});
});
});
@@ -1,284 +0,0 @@
/*
* 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 { Config } from '@backstage/config';
import { CorsOptions } from 'cors';
import { Minimatch } from 'minimatch';
export type BaseOptions = {
listenPort?: string | number;
listenHost?: string;
};
export type HttpsSettings = {
certificate: CertificateGenerationOptions | CertificateReferenceOptions;
};
export type CertificateReferenceOptions = {
key: string;
cert: string;
};
export type CertificateGenerationOptions = {
hostname: string;
};
export type CertificateAttributes = {
commonName: string;
};
/**
* A map from CSP directive names to their values.
*/
export type CspOptions = Record<string, string[]>;
type StaticOrigin = boolean | string | RegExp | (boolean | string | RegExp)[];
type CustomOrigin = (
requestOrigin: string | undefined,
callback: (err: Error | null, origin?: StaticOrigin) => void,
) => void;
/**
* Reads some base options out of a config object.
*
* @param config - The root of a backend config object
* @returns A base options object
*
* @example
* ```json
* {
* baseUrl: "http://localhost:7007",
* listen: "0.0.0.0:7007"
* }
* ```
*/
export function readBaseOptions(config: Config): BaseOptions {
if (typeof config.get('listen') === 'string') {
// TODO(freben): Expand this to support more addresses and perhaps optional
const { host, port } = parseListenAddress(config.getString('listen'));
return removeUnknown({
listenPort: port,
listenHost: host,
});
}
const port = config.getOptional('listen.port');
if (
typeof port !== 'undefined' &&
typeof port !== 'number' &&
typeof port !== 'string'
) {
throw new Error(
`Invalid type in config for key 'backend.listen.port', got ${typeof port}, wanted string or number`,
);
}
return removeUnknown({
listenPort: port,
listenHost: config.getOptionalString('listen.host'),
baseUrl: config.getOptionalString('baseUrl'),
});
}
/**
* Attempts to read a CORS options object from the root of a config object.
*
* @param config - The root of a backend config object
* @returns A CORS options object, or undefined if not specified
*
* @example
* ```json
* {
* cors: {
* origin: "http://localhost:3000",
* credentials: true
* }
* }
* ```
*/
export function readCorsOptions(config: Config): CorsOptions | undefined {
const cc = config.getOptionalConfig('cors');
if (!cc) {
return undefined;
}
return removeUnknown({
origin: createCorsOriginMatcher(getOptionalStringOrStrings(cc, 'origin')),
methods: getOptionalStringOrStrings(cc, 'methods'),
allowedHeaders: getOptionalStringOrStrings(cc, 'allowedHeaders'),
exposedHeaders: getOptionalStringOrStrings(cc, 'exposedHeaders'),
credentials: cc.getOptionalBoolean('credentials'),
maxAge: cc.getOptionalNumber('maxAge'),
preflightContinue: cc.getOptionalBoolean('preflightContinue'),
optionsSuccessStatus: cc.getOptionalNumber('optionsSuccessStatus'),
});
}
/**
* Attempts to read a CSP options object from the root of a config object.
*
* @param config - The root of a backend config object
* @returns A CSP options object, or undefined if not specified. Values can be
* false as well, which means to remove the default behavior for that
* key.
*
* @example
* ```yaml
* backend:
* csp:
* connect-src: ["'self'", 'http:', 'https:']
* upgrade-insecure-requests: false
* ```
*/
export function readCspOptions(
config: Config,
): Record<string, string[] | false> | undefined {
const cc = config.getOptionalConfig('csp');
if (!cc) {
return undefined;
}
const result: Record<string, string[] | false> = {};
for (const key of cc.keys()) {
if (cc.get(key) === false) {
result[key] = false;
} else {
result[key] = cc.getStringArray(key);
}
}
return result;
}
/**
* Attempts to read a https settings object from the root of a config object.
*
* @param config - The root of a backend config object
* @returns A https settings object, or undefined if not specified
*
* @example
* ```json
* {
* https: {
* certificate: ...
* }
* }
* ```
*/
export function readHttpsSettings(config: Config): HttpsSettings | undefined {
const https = config.getOptional('https');
if (https === true) {
const baseUrl = config.getString('baseUrl');
let hostname;
try {
hostname = new URL(baseUrl).hostname;
} catch (error) {
throw new Error(`Invalid backend.baseUrl "${baseUrl}"`);
}
return { certificate: { hostname } };
}
const cc = config.getOptionalConfig('https');
if (!cc) {
return undefined;
}
const certificateConfig = cc.get('certificate');
const cfg = {
certificate: certificateConfig,
};
return removeUnknown(cfg as HttpsSettings);
}
function getOptionalStringOrStrings(
config: Config,
key: string,
): string | string[] | undefined {
const value = config.getOptional(key);
if (value === undefined || isStringOrStrings(value)) {
return value;
}
throw new Error(`Expected string or array of strings, got ${typeof value}`);
}
function createCorsOriginMatcher(
originValue: string | string[] | undefined,
): CustomOrigin | undefined {
if (originValue === undefined) {
return originValue;
}
if (!isStringOrStrings(originValue)) {
throw new Error(
`Expected string or array of strings, got ${typeof originValue}`,
);
}
const allowedOrigin =
typeof originValue === 'string' ? [originValue] : originValue;
const allowedOriginPatterns =
allowedOrigin?.map(
pattern => new Minimatch(pattern, { nocase: true, noglobstar: true }),
) ?? [];
return (origin, callback) => {
return callback(
null,
allowedOriginPatterns.some(pattern => pattern.match(origin ?? '')),
);
};
}
function isStringOrStrings(value: any): value is string | string[] {
return typeof value === 'string' || isStringArray(value);
}
function isStringArray(value: any): value is string[] {
if (!Array.isArray(value)) {
return false;
}
for (const v of value) {
if (typeof v !== 'string') {
return false;
}
}
return true;
}
function removeUnknown<T extends object>(obj: T): T {
return Object.fromEntries(
Object.entries(obj).filter(([, v]) => v !== undefined),
) as T;
}
function parseListenAddress(value: string): { host?: string; port?: number } {
const parts = value.split(':');
if (parts.length === 1) {
return { port: parseInt(parts[0], 10) };
}
if (parts.length === 2) {
return { host: parts[0], port: parseInt(parts[1], 10) };
}
throw new Error(
`Unable to parse listen address ${value}, expected <port> or <host>:<port>`,
);
}
@@ -1,215 +0,0 @@
/*
* 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 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<http.Server> {
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(
hostname: string,
logger?: LoggerService,
) {
const hasModules = await fs.pathExists('node_modules');
let certPath;
if (hasModules) {
certPath = resolvePath(
'node_modules/.cache/backstage-backend/dev-cert.pem',
);
await fs.ensureDir(dirname(certPath));
} else {
certPath = resolvePath('.dev-cert.pem');
}
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,
};
}
}
logger?.info('Generating new self-signed certificate');
const newCert = await createCertificate(hostname);
await fs.writeFile(certPath, newCert.cert + newCert.key, 'utf8');
return newCert;
}
async function createCertificate(hostname: string) {
const attributes = [
{
name: 'commonName',
value: 'dev-cert',
},
];
const sans = [
{
type: 2, // DNS
value: 'localhost',
},
{
type: 2,
value: 'localhost.localdomain',
},
{
type: 2,
value: '[::1]',
},
{
type: 7, // IP
ip: '127.0.0.1',
},
{
type: 7,
ip: 'fe80::1',
},
];
// Add hostname from backend.baseUrl if it doesn't already exist in our list of SANs
if (!sans.find(({ value, ip }) => value === hostname || ip === hostname)) {
sans.push(
IP_HOSTNAME_REGEX.test(hostname)
? {
type: 7,
ip: hostname,
}
: {
type: 2,
value: hostname,
},
);
}
const params = {
algorithm: 'sha256',
keySize: 2048,
days: 30,
extensions: [
{
name: 'keyUsage',
keyCertSign: true,
digitalSignature: true,
nonRepudiation: true,
keyEncipherment: true,
dataEncipherment: true,
},
{
name: 'extKeyUsage',
serverAuth: true,
clientAuth: true,
codeSigning: true,
timeStamping: true,
},
{
name: 'subjectAltName',
altNames: sans,
},
],
};
return new Promise<{ key: string; cert: string }>((resolve, reject) =>
require('selfsigned').generate(
attributes,
params,
(err: Error, bundle: { private: string; cert: string }) => {
if (err) {
reject(err);
} else {
resolve({ key: bundle.private, cert: bundle.cert });
}
},
),
);
}