Files
openstapps/backend/proxy/src/main.ts

341 lines
11 KiB
TypeScript

/*
* Copyright (C) 2022 StApps
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Logger} from '@openstapps/logger';
import Dockerode from 'dockerode';
import isCidr from 'is-cidr';
import mustache from 'mustache';
import path from 'path';
import * as semver from 'semver';
import {
configFile,
isFileType,
protocolHardeningParameters,
SSLFilePaths,
sslHardeningParameters,
SupportedLogFormats,
TemplateView,
} from './common.js';
import {readFile} from 'fs/promises';
import PortScanner from './port-scanner.js';
/* eslint-disable unicorn/no-await-expression-member */
/**
* Checks if a ContainerInfo matches a name and version regex
* @param name Name to check
* @param versionRegex Version regex to check
* @param container Container info for check
*/
export function containerMatchesRegex(
name: string,
versionRegex: RegExp,
container: Dockerode.ContainerInfo,
): boolean {
return (
typeof container.Labels['stapps.version'] === 'string' &&
container.Labels['stapps.version'].match(versionRegex) !== null &&
((typeof container.Labels['com.docker.compose.service'] === 'string' &&
container.Labels['com.docker.compose.service'] === name) ||
(typeof container.Labels['com.docker.stack.namespace'] === 'string' &&
container.Labels['com.docker.swarm.service.name'] ===
`${container.Labels['com.docker.stack.namespace']}_${name}`))
);
}
/**
* Gets Gateway (ip:port) of given ContainerInfo
*
* Returns an empty String if there is no Gateway.
* This assumes that a backend runs in the container and it exposes one port.
* @param container Container info of which to get address from
*/
export async function getGatewayOfStAppsBackend(container: Dockerode.ContainerInfo): Promise<string> {
if (container.Ports.length === 0) {
await Logger.error(`Container ${container.Names[0]} does not advertise any port.
Please expose a port if the container should be accessible by NGINX.`);
return '';
}
// Basic Docker network
if (container.Ports[0].IP !== undefined && container.Ports[0].PublicPort !== undefined) {
// ip:port
return `${container.Ports[0].IP}:${container.Ports[0].PublicPort}`;
}
// Docker Swarm network
if (
/* istanbul ignore next */
container.NetworkSettings?.Networks?.ingress?.IPAddress !== undefined &&
container.Ports[0].PrivatePort !== undefined
) {
const port = container.Ports[0].PrivatePort;
// Get a routable network connection
for (const network in container.NetworkSettings.Networks) {
if (await PortScanner.isPortFree(port, container.NetworkSettings.Networks[network].IPAddress)) {
Logger.info(
`${container.Names[0]} reachable via ${container.NetworkSettings.Networks[network].IPAddress}:${port}`,
);
return `${container.NetworkSettings.Networks[network].IPAddress}:${port}`;
}
}
}
await Logger.error(
`Couldn't infer ${container.Names[0]} network reachability. It's possible your current Docker network setup isn't supported yet.`,
);
return '';
}
/**
* Generates an upstream map
*
* It maps all stapps-backend-containers to an gateway.
* @param activeVersions List of active versions
* @param outdatedVersions List of outdated versions
* @param containers List of container infos
*/
export async function generateUpstreamMap(
activeVersions: string[],
outdatedVersions: string[],
containers: Dockerode.ContainerInfo[],
): Promise<string> {
let result = 'map $http_x_stapps_version $proxyurl {\n default unsupported;\n';
let upstreams = '';
let foundMatchingContainer = false;
// const backendContainer = containers.filter(container => container.Image.includes('backend'));
// // eslint-disable-next-line no-console
//console.log(JSON.stringify(backendContainer, undefined, 2));
// active versions
result += (
await Promise.all(
activeVersions.map(async activeVersionRegex => {
const upstreamName = activeVersionRegex.replaceAll(/[\\|.+]/g, '_');
let activeBackends = containers.filter(container => {
return containerMatchesRegex('backend', new RegExp(activeVersionRegex), container);
});
// .Labels['stapps.version'] is available
if (activeBackends.length > 0) {
activeBackends = activeBackends.sort((a, b) =>
semver.rcompare(a.Labels['stapps.version'], b.Labels['stapps.version']),
);
const activeBackendsVersions = activeBackends
.map(container => container.Labels['stapps.version'])
// eslint-disable-next-line unicorn/no-array-reduce
.reduce(
(map, element) => map.set(element, (map.get(element) || 0) + 1),
new Map<string, number>(),
);
for (const [version, occurrences] of activeBackendsVersions) {
if (occurrences > 1) {
await Logger.error(
`Omitting running version ${version} ! Multiple backends with this exact version are running.`,
);
activeBackends = activeBackends.filter(
container => container.Labels['stapps.version'] !== version,
);
}
}
if (activeBackends.length > 0) {
// not only duplicates
foundMatchingContainer = true;
const gatewayOfContainer = await getGatewayOfStAppsBackend(activeBackends[0]);
if (gatewayOfContainer.length > 0) {
upstreams += `\nupstream ${upstreamName} {\n server ${gatewayOfContainer};\n}`;
return ` \"~${activeVersionRegex}\" ${upstreamName};\n`;
}
}
}
Logger.warn('No backend for version', activeVersionRegex, 'found');
return ` \"~${activeVersionRegex}\" unavailable;\n`;
}),
)
).join('');
// outdated versions
result += outdatedVersions
.map(outdatedVersionRegex => {
return ` \"~${outdatedVersionRegex}\" outdated;`;
})
.join('\n');
result += '\n}';
if (!foundMatchingContainer) {
await Logger.error(
'No container with matching version label found. Please start a container with a matching version Label.',
);
}
return `${result}${upstreams}\n`;
}
/**
* Generates http or https listener
*/
export function generateListener(sslFilePaths: SSLFilePaths) {
let listener = '';
if (
sslFilePaths !== undefined &&
sslFilePaths.certificate !== undefined &&
isFileType(sslFilePaths.certificate, 'crt') &&
sslFilePaths.certificateChain !== undefined &&
isFileType(sslFilePaths.certificateChain, 'crt') &&
sslFilePaths.certificateKey !== undefined &&
isFileType(sslFilePaths.certificateKey, 'key') &&
sslFilePaths.dhparam !== undefined &&
isFileType(sslFilePaths.dhparam, 'pem')
) {
// https listener
listener = ` listen 443 ssl default_server;
ssl_certificate ${sslFilePaths.certificate};
ssl_certificate_key ${sslFilePaths.certificateKey};
ssl_trusted_certificate ${sslFilePaths.certificateChain};
ssl_dhparam ${sslFilePaths.dhparam};
${sslHardeningParameters}`;
} else {
// default http listener
listener = 'listen 80 default_server;';
Logger.warn('Https usage is not setup properly, falling back to http!');
}
listener = `${listener}
${protocolHardeningParameters}
`;
return listener;
}
/**
* Reads predefined server entry with metrics location
*/
export async function generateMetricsServer(logFormat: string, enableMetrics?: boolean): Promise<string> {
if (!enableMetrics) {
return '';
}
return renderTemplate(path.join('fixtures', 'metrics.template'), {
logFormat: logFormat,
});
}
/**
* Render a mustache template file with given view object
* @param path Path to template file
* @param view Data to render template with
*/
async function renderTemplate(path: string, view: unknown): Promise<string> {
const content = await readFile(path, 'utf8');
return mustache.render(content, view);
}
/**
* Generate allow list entries in CIDR notation that pass thru rate limiting
* @param entries Allow list entries that should be in CIDR notation
*/
function generateRateLimitAllowList(entries: string[]): string {
return entries
.filter(entry => isCidr(entry))
.map(entry => `${entry} 0;`)
.join('\n');
}
/**
* Returns view for nginx config file
* @param containers List of container info
*/
export async function getTemplateView(containers: Dockerode.ContainerInfo[]): Promise<TemplateView> {
const cors = await readFile('./fixtures/cors.template', 'utf8');
const visibleRoutesPromises = ['/'].map(async route => {
return renderTemplate(path.join('fixtures', 'visibleRoute.template'), {
cors,
route,
});
});
const hiddenRoutesPromises = configFile.hiddenRoutes.map(async route => {
return renderTemplate(path.join('fixtures', 'hiddenRoute.template'), {
cors,
route,
});
});
const logFormattingPromise = renderTemplate(path.join('fixtures', 'logFormatters.template'), {});
const logFormat =
configFile.logFormat in SupportedLogFormats
? SupportedLogFormats[configFile.logFormat]
: SupportedLogFormats.default;
return {
dockerVersionMap: await generateUpstreamMap(
configFile.activeVersions,
configFile.outdatedVersions,
containers,
),
hiddenRoutes: (await Promise.all(hiddenRoutesPromises)).join(''),
listener: generateListener(configFile.sslFilePaths),
logFormat: logFormat,
logFormatters: await logFormattingPromise,
metrics: await generateMetricsServer(logFormat, configFile.metrics),
rateLimitAllowList: generateRateLimitAllowList(configFile.rateLimitAllowList),
staticRoute: await renderTemplate(path.join('fixtures', 'staticRoute.template'), {cors}),
visibleRoutes: (await Promise.all(visibleRoutesPromises)).join(''),
};
}
/**
* Read the list of docker containers
* @param pathToDockerSocket Path to docker socket
*/
export async function getContainers(
pathToDockerSocket = '/var/run/docker.sock',
): Promise<Dockerode.ContainerInfo[]> {
const docker = new Dockerode({
socketPath: pathToDockerSocket,
});
const containers = await docker.listContainers();
/* istanbul ignore next */
if (containers.length === 0) {
throw new Error(
`No running docker containers found.
Please check if docker is running and Node.js can access the docker socket (${pathToDockerSocket})`,
);
}
/* istanbul ignore next */
return containers;
}