const _ = require('lodash'); const fs = require('fs'); const logger = require('../logger').nginx; const utils = require('../lib/utils'); const error = require('../lib/error'); const { Liquid } = require('liquidjs'); const debug_mode = process.env.NODE_ENV !== 'production' || !!process.env.DEBUG; const internalNginx = { /** * This will: * - test the nginx config first to make sure it's OK * - create / recreate the config for the host * - test again * - IF OK: update the meta with online status * - IF BAD: update the meta with offline status and remove the config entirely * - then reload nginx * * @param {Object|String} model * @param {String} host_type * @param {Object} host * @returns {Promise} */ configure: (model, host_type, host) => { let combined_meta = {}; return internalNginx.test() .then(() => { // Nginx is OK // We're deleting this config regardless. return internalNginx.deleteConfig(host_type, host); // Don't throw errors, as the file may not exist at all }) .then(() => { return internalNginx.generateConfig(host_type, host); }) .then(() => { // Test nginx again and update meta with result return internalNginx.test() .then(() => { // nginx is ok combined_meta = _.assign({}, host.meta, { nginx_online: true, nginx_err: null }); return model .query() .where('id', host.id) .patch({ meta: combined_meta }); }) .catch((err) => { // Remove the error_log line because it's a docker-ism false positive that doesn't need to be reported. // It will always look like this: // nginx: [alert] could not open error log file: open() "/var/log/nginx/error.log" failed (6: No such device or address) let valid_lines = []; let err_lines = err.message.split('\n'); err_lines.map(function (line) { if (line.indexOf('/var/log/nginx/error.log') === -1) { valid_lines.push(line); } }); if (debug_mode) { logger.error('Nginx test failed:', valid_lines.join('\n')); } // config is bad, update meta and delete config combined_meta = _.assign({}, host.meta, { nginx_online: false, nginx_err: valid_lines.join('\n') }); return model .query() .where('id', host.id) .patch({ meta: combined_meta }) .then(() => { return internalNginx.deleteConfig(host_type, host, true); }); }); }) .then(() => { return internalNginx.reload(); }) .then(() => { return combined_meta; }); }, /** * @returns {Promise} */ test: () => { if (debug_mode) { logger.info('Testing Nginx configuration'); } return utils.exec('/usr/sbin/nginx -t -g "error_log off;"'); }, /** * @returns {Promise} */ reload: () => { return internalNginx.test() .then(() => { logger.info('Reloading Nginx'); return utils.exec('/usr/sbin/nginx -s reload'); }); }, /** * @param {String} host_type * @param {Integer} host_id * @returns {String} */ getConfigName: (host_type, host_id) => { host_type = host_type.replace(new RegExp('-', 'g'), '_'); if (host_type === 'default') { return '/data/nginx/default_host/site.conf'; } return '/data/nginx/' + host_type + '/' + host_id + '.conf'; }, /** * Generates custom locations * @param {Object} host * @returns {Promise} */ renderLocations: (host) => { return new Promise((resolve, reject) => { let template; try { template = fs.readFileSync(__dirname + '/../templates/_location.conf', {encoding: 'utf8'}); } catch (err) { reject(new error.ConfigurationError(err.message)); return; } let renderer = new Liquid(); let renderedLocations = ''; const locationRendering = async () => { for (let i = 0; i < host.locations.length; i++) { let locationCopy = Object.assign({}, host.locations[i]); if (locationCopy.forward_host.indexOf('/') > -1) { const splitted = locationCopy.forward_host.split('/'); locationCopy.forward_host = splitted.shift(); locationCopy.forward_path = `/${splitted.join('/')}`; } // eslint-disable-next-line renderedLocations += await renderer.parseAndRender(template, locationCopy); } }; locationRendering().then(() => resolve(renderedLocations)); }); }, /** * @param {String} host_type * @param {Object} host * @returns {Promise} */ generateConfig: (host_type, host) => { host_type = host_type.replace(new RegExp('-', 'g'), '_'); if (debug_mode) { logger.info('Generating ' + host_type + ' Config:', host); } let renderEngine = new Liquid({ root: __dirname + '/../templates/' }); return new Promise((resolve, reject) => { let template = null; let filename = internalNginx.getConfigName(host_type, host.id); try { template = fs.readFileSync(__dirname + '/../templates/' + host_type + '.conf', {encoding: 'utf8'}); } catch (err) { reject(new error.ConfigurationError(err.message)); return; } let locationsPromise; let origLocations; // Manipulate the data a bit before sending it to the template if (host_type !== 'default') { host.use_default_location = true; if (typeof host.advanced_config !== 'undefined' && host.advanced_config) { host.use_default_location = !internalNginx.advancedConfigHasDefaultLocation(host.advanced_config); } } if (host.locations) { origLocations = [].concat(host.locations); locationsPromise = internalNginx.renderLocations(host).then((renderedLocations) => { host.locations = renderedLocations; }); // Allow someone who is using / custom location path to use it, and skip the default / location _.map(host.locations, (location) => { if (location.path === '/') { host.use_default_location = false; } }); } else { locationsPromise = Promise.resolve(); } // Set the IPv6 setting for the host host.ipv6 = internalNginx.ipv6Enabled(); locationsPromise.then(() => { renderEngine .parseAndRender(template, host) .then((config_text) => { fs.writeFileSync(filename, config_text, {encoding: 'utf8'}); if (debug_mode) { logger.success('Wrote config:', filename, config_text); } // Restore locations array host.locations = origLocations; resolve(true); }) .catch((err) => { if (debug_mode) { logger.warn('Could not write ' + filename + ':', err.message); } reject(new error.ConfigurationError(err.message)); }); }); }); }, /** * This generates a temporary nginx config listening on port 80 for the domain names listed * in the certificate setup. It allows the letsencrypt acme challenge to be requested by letsencrypt * when requesting a certificate without having a hostname set up already. * * @param {Object} certificate * @returns {Promise} */ generateLetsEncryptRequestConfig: (certificate) => { if (debug_mode) { logger.info('Generating LetsEncrypt Request Config:', certificate); } let renderEngine = new Liquid({ root: __dirname + '/../templates/' }); return new Promise((resolve, reject) => { let template = null; let filename = '/data/nginx/temp/letsencrypt_' + certificate.id + '.conf'; try { template = fs.readFileSync(__dirname + '/../templates/letsencrypt-request.conf', {encoding: 'utf8'}); } catch (err) { reject(new error.ConfigurationError(err.message)); return; } certificate.ipv6 = internalNginx.ipv6Enabled(); renderEngine .parseAndRender(template, certificate) .then((config_text) => { fs.writeFileSync(filename, config_text, {encoding: 'utf8'}); if (debug_mode) { logger.success('Wrote config:', filename, config_text); } resolve(true); }) .catch((err) => { if (debug_mode) { logger.warn('Could not write ' + filename + ':', err.message); } reject(new error.ConfigurationError(err.message)); }); }); }, /** * This removes the temporary nginx config file generated by `generateLetsEncryptRequestConfig` * * @param {Object} certificate * @param {Boolean} [throw_errors] * @returns {Promise} */ deleteLetsEncryptRequestConfig: (certificate, throw_errors) => { return new Promise((resolve, reject) => { try { let config_file = '/data/nginx/temp/letsencrypt_' + certificate.id + '.conf'; if (debug_mode) { logger.warn('Deleting nginx config: ' + config_file); } fs.unlinkSync(config_file); } catch (err) { if (debug_mode) { logger.warn('Could not delete config:', err.message); } if (throw_errors) { reject(err); } } resolve(); }); }, /** * @param {String} host_type * @param {Object} [host] * @param {Boolean} [throw_errors] * @returns {Promise} */ deleteConfig: (host_type, host, throw_errors) => { host_type = host_type.replace(new RegExp('-', 'g'), '_'); return new Promise((resolve, reject) => { try { let config_file = internalNginx.getConfigName(host_type, typeof host === 'undefined' ? 0 : host.id); if (debug_mode) { logger.warn('Deleting nginx config: ' + config_file); } fs.unlinkSync(config_file); } catch (err) { if (debug_mode) { logger.warn('Could not delete config:', err.message); } if (throw_errors) { reject(err); } } resolve(); }); }, /** * @param {String} host_type * @param {Array} hosts * @returns {Promise} */ bulkGenerateConfigs: (host_type, hosts) => { let promises = []; hosts.map(function (host) { promises.push(internalNginx.generateConfig(host_type, host)); }); return Promise.all(promises); }, /** * @param {String} host_type * @param {Array} hosts * @param {Boolean} [throw_errors] * @returns {Promise} */ bulkDeleteConfigs: (host_type, hosts, throw_errors) => { let promises = []; hosts.map(function (host) { promises.push(internalNginx.deleteConfig(host_type, host, throw_errors)); }); return Promise.all(promises); }, /** * @param {string} config * @returns {boolean} */ advancedConfigHasDefaultLocation: function (config) { return !!config.match(/^(?:.*;)?\s*?location\s*?\/\s*?{/im); }, /** * @returns {boolean} */ ipv6Enabled: function () { if (typeof process.env.DISABLE_IPV6 !== 'undefined') { const disabled = process.env.DISABLE_IPV6.toLowerCase(); return !(disabled === 'on' || disabled === 'true' || disabled === '1' || disabled === 'yes'); } return true; } }; module.exports = internalNginx;