mirror of
https://gitlab.com/openstapps/openstapps.git
synced 2026-01-18 07:32:54 +00:00
341 lines
11 KiB
TypeScript
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;
|
|
}
|