436 lines
11 KiB
JavaScript
436 lines
11 KiB
JavaScript
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) => {
|
|
|
|
//logger.info('host = ' + JSON.stringify(host, null, 2));
|
|
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({
|
|
root: __dirname + '/../templates/'
|
|
});
|
|
let renderedLocations = '';
|
|
|
|
const locationRendering = async () => {
|
|
for (let i = 0; i < host.locations.length; i++) {
|
|
let locationCopy = Object.assign({}, {access_list_id: host.access_list_id}, {certificate_id: host.certificate_id},
|
|
{ssl_forced: host.ssl_forced}, {caching_enabled: host.caching_enabled}, {block_exploits: host.block_exploits},
|
|
{allow_websocket_upgrade: host.allow_websocket_upgrade}, {http2_support: host.http2_support},
|
|
{hsts_enabled: host.hsts_enabled}, {hsts_subdomains: host.hsts_subdomains}, {access_list: host.access_list},
|
|
{certificate: host.certificate}, 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('/')}`;
|
|
}
|
|
|
|
//logger.info('locationCopy = ' + JSON.stringify(locationCopy, null, 2));
|
|
|
|
// 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);
|
|
}
|
|
|
|
// logger.info('host = ' + JSON.stringify(host, null, 2));
|
|
|
|
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) {
|
|
//logger.info ('host.locations = ' + JSON.stringify(host.locations, null, 2));
|
|
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;
|