nginx.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449
  1. const _ = require('lodash');
  2. const fs = require('fs');
  3. const logger = require('../logger').nginx;
  4. const utils = require('../lib/utils');
  5. const error = require('../lib/error');
  6. const { Liquid } = require('liquidjs');
  7. const debug_mode = process.env.NODE_ENV !== 'production' || !!process.env.DEBUG;
  8. const internalNginx = {
  9. /**
  10. * This will:
  11. * - test the nginx config first to make sure it's OK
  12. * - create / recreate the config for the host
  13. * - test again
  14. * - IF OK: update the meta with online status
  15. * - IF BAD: update the meta with offline status and remove the config entirely
  16. * - then reload nginx
  17. *
  18. * @param {Object|String} model
  19. * @param {String} host_type
  20. * @param {Object} host
  21. * @returns {Promise}
  22. */
  23. configure: (model, host_type, host) => {
  24. let combined_meta = {};
  25. return internalNginx.test()
  26. .then(() => {
  27. // Nginx is OK
  28. // We're deleting this config regardless.
  29. // Don't throw errors, as the file may not exist at all
  30. // Delete the .err file too
  31. return internalNginx.deleteConfig(host_type, host, false, true);
  32. })
  33. .then(() => {
  34. return internalNginx.generateConfig(host_type, host);
  35. })
  36. .then(() => {
  37. // Test nginx again and update meta with result
  38. return internalNginx.test()
  39. .then(() => {
  40. // nginx is ok
  41. combined_meta = _.assign({}, host.meta, {
  42. nginx_online: true,
  43. nginx_err: null
  44. });
  45. return model
  46. .query()
  47. .where('id', host.id)
  48. .patch({
  49. meta: combined_meta
  50. });
  51. })
  52. .catch((err) => {
  53. // Remove the error_log line because it's a docker-ism false positive that doesn't need to be reported.
  54. // It will always look like this:
  55. // nginx: [alert] could not open error log file: open() "/var/log/nginx/error.log" failed (6: No such device or address)
  56. let valid_lines = [];
  57. let err_lines = err.message.split('\n');
  58. err_lines.map(function (line) {
  59. if (line.indexOf('/var/log/nginx/error.log') === -1) {
  60. valid_lines.push(line);
  61. }
  62. });
  63. if (debug_mode) {
  64. logger.error('Nginx test failed:', valid_lines.join('\n'));
  65. }
  66. // config is bad, update meta and delete config
  67. combined_meta = _.assign({}, host.meta, {
  68. nginx_online: false,
  69. nginx_err: valid_lines.join('\n')
  70. });
  71. return model
  72. .query()
  73. .where('id', host.id)
  74. .patch({
  75. meta: combined_meta
  76. })
  77. .then(() => {
  78. internalNginx.renameConfigAsError(host_type, host)
  79. })
  80. .then(() => {
  81. return internalNginx.deleteConfig(host_type, host, true);
  82. });
  83. });
  84. })
  85. .then(() => {
  86. return internalNginx.reload();
  87. })
  88. .then(() => {
  89. return combined_meta;
  90. });
  91. },
  92. /**
  93. * @returns {Promise}
  94. */
  95. test: () => {
  96. if (debug_mode) {
  97. logger.info('Testing Nginx configuration');
  98. }
  99. return utils.exec('/usr/sbin/nginx -t -g "error_log off;"');
  100. },
  101. /**
  102. * @returns {Promise}
  103. */
  104. reload: () => {
  105. return internalNginx.test()
  106. .then(() => {
  107. logger.info('Reloading Nginx');
  108. return utils.exec('/usr/sbin/nginx -s reload');
  109. });
  110. },
  111. /**
  112. * @param {String} host_type
  113. * @param {Integer} host_id
  114. * @returns {String}
  115. */
  116. getConfigName: (host_type, host_id) => {
  117. if (host_type === 'default') {
  118. return '/data/nginx/default_host/site.conf';
  119. }
  120. return '/data/nginx/' + internalNginx.getFileFriendlyHostType(host_type) + '/' + host_id + '.conf';
  121. },
  122. /**
  123. * Generates custom locations
  124. * @param {Object} host
  125. * @returns {Promise}
  126. */
  127. renderLocations: (host) => {
  128. //logger.info('host = ' + JSON.stringify(host, null, 2));
  129. return new Promise((resolve, reject) => {
  130. let template;
  131. try {
  132. template = fs.readFileSync(__dirname + '/../templates/_location.conf', {encoding: 'utf8'});
  133. } catch (err) {
  134. reject(new error.ConfigurationError(err.message));
  135. return;
  136. }
  137. let renderer = new Liquid({
  138. root: __dirname + '/../templates/'
  139. });
  140. let renderedLocations = '';
  141. const locationRendering = async () => {
  142. for (let i = 0; i < host.locations.length; i++) {
  143. let locationCopy = Object.assign({}, {access_list_id: host.access_list_id}, {certificate_id: host.certificate_id},
  144. {ssl_forced: host.ssl_forced}, {caching_enabled: host.caching_enabled}, {block_exploits: host.block_exploits},
  145. {allow_websocket_upgrade: host.allow_websocket_upgrade}, {http2_support: host.http2_support},
  146. {hsts_enabled: host.hsts_enabled}, {hsts_subdomains: host.hsts_subdomains}, {access_list: host.access_list},
  147. {certificate: host.certificate}, host.locations[i]);
  148. if (locationCopy.forward_host.indexOf('/') > -1) {
  149. const splitted = locationCopy.forward_host.split('/');
  150. locationCopy.forward_host = splitted.shift();
  151. locationCopy.forward_path = `/${splitted.join('/')}`;
  152. }
  153. //logger.info('locationCopy = ' + JSON.stringify(locationCopy, null, 2));
  154. // eslint-disable-next-line
  155. renderedLocations += await renderer.parseAndRender(template, locationCopy);
  156. }
  157. };
  158. locationRendering().then(() => resolve(renderedLocations));
  159. });
  160. },
  161. /**
  162. * @param {String} host_type
  163. * @param {Object} host
  164. * @returns {Promise}
  165. */
  166. generateConfig: (host_type, host) => {
  167. const nice_host_type = internalNginx.getFileFriendlyHostType(host_type);
  168. if (debug_mode) {
  169. logger.info('Generating ' + nice_host_type + ' Config:', JSON.stringify(host, null, 2));
  170. }
  171. // logger.info('host = ' + JSON.stringify(host, null, 2));
  172. let renderEngine = new Liquid({
  173. root: __dirname + '/../templates/'
  174. });
  175. return new Promise((resolve, reject) => {
  176. let template = null;
  177. let filename = internalNginx.getConfigName(nice_host_type, host.id);
  178. try {
  179. template = fs.readFileSync(__dirname + '/../templates/' + nice_host_type + '.conf', {encoding: 'utf8'});
  180. } catch (err) {
  181. reject(new error.ConfigurationError(err.message));
  182. return;
  183. }
  184. let locationsPromise;
  185. let origLocations;
  186. // Manipulate the data a bit before sending it to the template
  187. if (nice_host_type !== 'default') {
  188. host.use_default_location = true;
  189. if (typeof host.advanced_config !== 'undefined' && host.advanced_config) {
  190. host.use_default_location = !internalNginx.advancedConfigHasDefaultLocation(host.advanced_config);
  191. }
  192. }
  193. if (host.locations) {
  194. //logger.info ('host.locations = ' + JSON.stringify(host.locations, null, 2));
  195. origLocations = [].concat(host.locations);
  196. locationsPromise = internalNginx.renderLocations(host).then((renderedLocations) => {
  197. host.locations = renderedLocations;
  198. });
  199. // Allow someone who is using / custom location path to use it, and skip the default / location
  200. _.map(host.locations, (location) => {
  201. if (location.path === '/') {
  202. host.use_default_location = false;
  203. }
  204. });
  205. } else {
  206. locationsPromise = Promise.resolve();
  207. }
  208. // Set the IPv6 setting for the host
  209. host.ipv6 = internalNginx.ipv6Enabled();
  210. locationsPromise.then(() => {
  211. renderEngine
  212. .parseAndRender(template, host)
  213. .then((config_text) => {
  214. fs.writeFileSync(filename, config_text, {encoding: 'utf8'});
  215. if (debug_mode) {
  216. logger.success('Wrote config:', filename, config_text);
  217. }
  218. // Restore locations array
  219. host.locations = origLocations;
  220. resolve(true);
  221. })
  222. .catch((err) => {
  223. if (debug_mode) {
  224. logger.warn('Could not write ' + filename + ':', err.message);
  225. }
  226. reject(new error.ConfigurationError(err.message));
  227. });
  228. });
  229. });
  230. },
  231. /**
  232. * This generates a temporary nginx config listening on port 80 for the domain names listed
  233. * in the certificate setup. It allows the letsencrypt acme challenge to be requested by letsencrypt
  234. * when requesting a certificate without having a hostname set up already.
  235. *
  236. * @param {Object} certificate
  237. * @returns {Promise}
  238. */
  239. generateLetsEncryptRequestConfig: (certificate) => {
  240. if (debug_mode) {
  241. logger.info('Generating LetsEncrypt Request Config:', certificate);
  242. }
  243. let renderEngine = new Liquid({
  244. root: __dirname + '/../templates/'
  245. });
  246. return new Promise((resolve, reject) => {
  247. let template = null;
  248. let filename = '/data/nginx/temp/letsencrypt_' + certificate.id + '.conf';
  249. try {
  250. template = fs.readFileSync(__dirname + '/../templates/letsencrypt-request.conf', {encoding: 'utf8'});
  251. } catch (err) {
  252. reject(new error.ConfigurationError(err.message));
  253. return;
  254. }
  255. certificate.ipv6 = internalNginx.ipv6Enabled();
  256. renderEngine
  257. .parseAndRender(template, certificate)
  258. .then((config_text) => {
  259. fs.writeFileSync(filename, config_text, {encoding: 'utf8'});
  260. if (debug_mode) {
  261. logger.success('Wrote config:', filename, config_text);
  262. }
  263. resolve(true);
  264. })
  265. .catch((err) => {
  266. if (debug_mode) {
  267. logger.warn('Could not write ' + filename + ':', err.message);
  268. }
  269. reject(new error.ConfigurationError(err.message));
  270. });
  271. });
  272. },
  273. /**
  274. * A simple wrapper around unlinkSync that writes to the logger
  275. *
  276. * @param {String} filename
  277. */
  278. deleteFile: (filename) => {
  279. logger.debug('Deleting file: ' + filename);
  280. try {
  281. fs.unlinkSync(filename);
  282. } catch (err) {
  283. logger.debug('Could not delete file:', JSON.stringify(err, null, 2));
  284. };
  285. },
  286. /**
  287. *
  288. * @param {String} host_type
  289. * @returns String
  290. */
  291. getFileFriendlyHostType: (host_type) => {
  292. return host_type.replace(new RegExp('-', 'g'), '_');
  293. },
  294. /**
  295. * This removes the temporary nginx config file generated by `generateLetsEncryptRequestConfig`
  296. *
  297. * @param {Object} certificate
  298. * @returns {Promise}
  299. */
  300. deleteLetsEncryptRequestConfig: (certificate) => {
  301. const config_file = '/data/nginx/temp/letsencrypt_' + certificate.id + '.conf';
  302. return new Promise((resolve/*, reject*/) => {
  303. internalNginx.deleteFile(config_file);
  304. resolve();
  305. });
  306. },
  307. /**
  308. * @param {String} host_type
  309. * @param {Object} [host]
  310. * @param {Boolean} [delete_err_file]
  311. * @returns {Promise}
  312. */
  313. deleteConfig: (host_type, host, delete_err_file) => {
  314. const config_file = internalNginx.getConfigName(internalNginx.getFileFriendlyHostType(host_type), typeof host === 'undefined' ? 0 : host.id);
  315. const config_file_err = config_file + '.err';
  316. return new Promise((resolve/*, reject*/) => {
  317. internalNginx.deleteFile(config_file);
  318. if (delete_err_file) {
  319. internalNginx.deleteFile(config_file_err);
  320. }
  321. resolve();
  322. });
  323. },
  324. /**
  325. * @param {String} host_type
  326. * @param {Object} [host]
  327. * @returns {Promise}
  328. */
  329. renameConfigAsError: (host_type, host) => {
  330. const config_file = internalNginx.getConfigName(internalNginx.getFileFriendlyHostType(host_type), typeof host === 'undefined' ? 0 : host.id);
  331. const config_file_err = config_file + '.err';
  332. return new Promise((resolve/*, reject*/) => {
  333. fs.unlink(config_file, () => {
  334. // ignore result, continue
  335. fs.rename(config_file, config_file_err, () => {
  336. // also ignore result, as this is a debugging informative file anyway
  337. resolve();
  338. });
  339. });
  340. });
  341. },
  342. /**
  343. * @param {String} host_type
  344. * @param {Array} hosts
  345. * @returns {Promise}
  346. */
  347. bulkGenerateConfigs: (host_type, hosts) => {
  348. let promises = [];
  349. hosts.map(function (host) {
  350. promises.push(internalNginx.generateConfig(host_type, host));
  351. });
  352. return Promise.all(promises);
  353. },
  354. /**
  355. * @param {String} host_type
  356. * @param {Array} hosts
  357. * @returns {Promise}
  358. */
  359. bulkDeleteConfigs: (host_type, hosts) => {
  360. let promises = [];
  361. hosts.map(function (host) {
  362. promises.push(internalNginx.deleteConfig(host_type, host, true));
  363. });
  364. return Promise.all(promises);
  365. },
  366. /**
  367. * @param {string} config
  368. * @returns {boolean}
  369. */
  370. advancedConfigHasDefaultLocation: function (config) {
  371. return !!config.match(/^(?:.*;)?\s*?location\s*?\/\s*?{/im);
  372. },
  373. /**
  374. * @returns {boolean}
  375. */
  376. ipv6Enabled: function () {
  377. if (typeof process.env.DISABLE_IPV6 !== 'undefined') {
  378. const disabled = process.env.DISABLE_IPV6.toLowerCase();
  379. return !(disabled === 'on' || disabled === 'true' || disabled === '1' || disabled === 'yes');
  380. }
  381. return true;
  382. }
  383. };
  384. module.exports = internalNginx;