nginx.js 12 KB

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