/* * 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 {render} from 'mustache'; import path from 'path'; import * as semver from 'semver'; import { asyncReadFile, ConfigFile, isFileType, protocolHardeningParameters, SSLFilePaths, sslHardeningParameters, TemplateView, } from './common'; /* eslint-disable unicorn/prefer-module */ /* eslint-disable unicorn/no-await-expression-member */ // eslint-disable-next-line @typescript-eslint/no-var-requires const nodePortScanner = require('node-port-scanner'); /** * 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 (typeof container.Ports[0].IP !== 'undefined' && typeof container.Ports[0].PublicPort !== 'undefined') { // ip:port return `${container.Ports[0].IP}:${container.Ports[0].PublicPort}`; } // Docker Swarm network if ( /* istanbul ignore next */ typeof container.NetworkSettings?.Networks?.ingress?.IPAddress !== 'undefined' && typeof container.Ports[0].PrivatePort !== 'undefined' ) { const port = container.Ports[0].PrivatePort; // Get a routable network connection for (const network in container.NetworkSettings.Networks) { const scan = await nodePortScanner(container.NetworkSettings.Networks[network].IPAddress, [port]); if ((scan.ports.open as Array).includes(port)) { 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.replace(/[\\|.+]/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'); // eslint-disable-next-line prettier/prettier 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 ( typeof sslFilePaths !== 'undefined' && typeof sslFilePaths.certificate !== 'undefined' && isFileType(sslFilePaths.certificate, 'crt') && typeof sslFilePaths.certificateChain !== 'undefined' && isFileType(sslFilePaths.certificateChain, 'crt') && typeof sslFilePaths.certificateKey !== 'undefined' && isFileType(sslFilePaths.certificateKey, 'key') && typeof 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; } /** * 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 asyncReadFile(path, 'utf8'); return 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 { delete require.cache[require.resolve('config')]; // eslint-disable-next-line @typescript-eslint/no-var-requires const config = require('config'); const configFile = config as ConfigFile; const cors = await asyncReadFile('./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, }); }); return { dockerVersionMap: await generateUpstreamMap( configFile.activeVersions, configFile.outdatedVersions, containers, ), hiddenRoutes: (await Promise.all(hiddenRoutesPromises)).join(''), listener: generateListener(configFile.sslFilePaths), 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; }