/* * 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 . */ 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 { 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 { 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(), ); 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 { 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 { 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 { 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 { 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; }