backend-app-api: forklift sevrice factory config from backend-common
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user