nginx.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437
  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 { debug, 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. debug(logger, "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. debug(logger, "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. debug(logger, `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. // For redirection hosts, if the scheme is not http or https, set it to $scheme
  193. if (nice_host_type === "redirection_host" && ['http', 'https'].indexOf(host.forward_scheme.toLowerCase()) === -1) {
  194. host.forward_scheme = "$scheme";
  195. }
  196. if (host.locations) {
  197. //logger.info ('host.locations = ' + JSON.stringify(host.locations, null, 2));
  198. origLocations = [].concat(host.locations);
  199. locationsPromise = internalNginx.renderLocations(host).then((renderedLocations) => {
  200. host.locations = renderedLocations;
  201. });
  202. // Allow someone who is using / custom location path to use it, and skip the default / location
  203. _.map(host.locations, (location) => {
  204. if (location.path === "/") {
  205. host.use_default_location = false;
  206. }
  207. });
  208. } else {
  209. locationsPromise = Promise.resolve();
  210. }
  211. // Set the IPv6 setting for the host
  212. host.ipv6 = internalNginx.ipv6Enabled();
  213. locationsPromise.then(() => {
  214. renderEngine
  215. .parseAndRender(template, host)
  216. .then((config_text) => {
  217. fs.writeFileSync(filename, config_text, { encoding: "utf8" });
  218. debug(logger, "Wrote config:", filename, config_text);
  219. // Restore locations array
  220. host.locations = origLocations;
  221. resolve(true);
  222. })
  223. .catch((err) => {
  224. debug(logger, `Could not write ${filename}:`, err.message);
  225. reject(new errs.ConfigurationError(err.message));
  226. });
  227. });
  228. });
  229. },
  230. /**
  231. * This generates a temporary nginx config listening on port 80 for the domain names listed
  232. * in the certificate setup. It allows the letsencrypt acme challenge to be requested by letsencrypt
  233. * when requesting a certificate without having a hostname set up already.
  234. *
  235. * @param {Object} certificate
  236. * @returns {Promise}
  237. */
  238. generateLetsEncryptRequestConfig: (certificate) => {
  239. debug(logger, "Generating LetsEncrypt Request Config:", certificate);
  240. const renderEngine = utils.getRenderEngine();
  241. return new Promise((resolve, reject) => {
  242. let template = null;
  243. const filename = `/data/nginx/temp/letsencrypt_${certificate.id}.conf`;
  244. try {
  245. template = fs.readFileSync(`${__dirname}/../templates/letsencrypt-request.conf`, { encoding: "utf8" });
  246. } catch (err) {
  247. reject(new errs.ConfigurationError(err.message));
  248. return;
  249. }
  250. certificate.ipv6 = internalNginx.ipv6Enabled();
  251. renderEngine
  252. .parseAndRender(template, certificate)
  253. .then((config_text) => {
  254. fs.writeFileSync(filename, config_text, { encoding: "utf8" });
  255. debug(logger, "Wrote config:", filename, config_text);
  256. resolve(true);
  257. })
  258. .catch((err) => {
  259. debug(logger, `Could not write ${filename}:`, err.message);
  260. reject(new errs.ConfigurationError(err.message));
  261. });
  262. });
  263. },
  264. /**
  265. * A simple wrapper around unlinkSync that writes to the logger
  266. *
  267. * @param {String} filename
  268. */
  269. deleteFile: (filename) => {
  270. if (!fs.existsSync(filename)) {
  271. return;
  272. }
  273. try {
  274. debug(logger, `Deleting file: ${filename}`);
  275. fs.unlinkSync(filename);
  276. } catch (err) {
  277. debug(logger, "Could not delete file:", JSON.stringify(err, null, 2));
  278. }
  279. },
  280. /**
  281. *
  282. * @param {String} host_type
  283. * @returns String
  284. */
  285. getFileFriendlyHostType: (host_type) => {
  286. return host_type.replace(/-/g, "_");
  287. },
  288. /**
  289. * This removes the temporary nginx config file generated by `generateLetsEncryptRequestConfig`
  290. *
  291. * @param {Object} certificate
  292. * @returns {Promise}
  293. */
  294. deleteLetsEncryptRequestConfig: (certificate) => {
  295. const config_file = `/data/nginx/temp/letsencrypt_${certificate.id}.conf`;
  296. return new Promise((resolve /*, reject*/) => {
  297. internalNginx.deleteFile(config_file);
  298. resolve();
  299. });
  300. },
  301. /**
  302. * @param {String} host_type
  303. * @param {Object} [host]
  304. * @param {Boolean} [delete_err_file]
  305. * @returns {Promise}
  306. */
  307. deleteConfig: (host_type, host, delete_err_file) => {
  308. const config_file = internalNginx.getConfigName(
  309. internalNginx.getFileFriendlyHostType(host_type),
  310. typeof host === "undefined" ? 0 : host.id,
  311. );
  312. const config_file_err = `${config_file}.err`;
  313. return new Promise((resolve /*, reject*/) => {
  314. internalNginx.deleteFile(config_file);
  315. if (delete_err_file) {
  316. internalNginx.deleteFile(config_file_err);
  317. }
  318. resolve();
  319. });
  320. },
  321. /**
  322. * @param {String} host_type
  323. * @param {Object} [host]
  324. * @returns {Promise}
  325. */
  326. renameConfigAsError: (host_type, host) => {
  327. const config_file = internalNginx.getConfigName(
  328. internalNginx.getFileFriendlyHostType(host_type),
  329. typeof host === "undefined" ? 0 : host.id,
  330. );
  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} hostType
  344. * @param {Array} hosts
  345. * @returns {Promise}
  346. */
  347. bulkGenerateConfigs: (hostType, hosts) => {
  348. const promises = [];
  349. hosts.map((host) => {
  350. promises.push(internalNginx.generateConfig(hostType, host));
  351. return true;
  352. });
  353. return Promise.all(promises);
  354. },
  355. /**
  356. * @param {String} host_type
  357. * @param {Array} hosts
  358. * @returns {Promise}
  359. */
  360. bulkDeleteConfigs: (host_type, hosts) => {
  361. const promises = [];
  362. hosts.map((host) => {
  363. promises.push(internalNginx.deleteConfig(host_type, host, true));
  364. return true;
  365. });
  366. return Promise.all(promises);
  367. },
  368. /**
  369. * @param {string} config
  370. * @returns {boolean}
  371. */
  372. advancedConfigHasDefaultLocation: (cfg) => !!cfg.match(/^(?:.*;)?\s*?location\s*?\/\s*?{/im),
  373. /**
  374. * @returns {boolean}
  375. */
  376. ipv6Enabled: () => {
  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. export default internalNginx;