nginx.js 12 KB

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