certificate.js 36 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343
  1. import fs from "node:fs";
  2. import https from "node:https";
  3. import archiver from "archiver";
  4. import _ from "lodash";
  5. import moment from "moment";
  6. import path from "path";
  7. import tempWrite from "temp-write";
  8. import dnsPlugins from "../certbot/dns-plugins.json" with { type: "json" };
  9. import { installPlugin } from "../lib/certbot.js";
  10. import { useLetsencryptServer, useLetsencryptStaging } from "../lib/config.js";
  11. import error from "../lib/error.js";
  12. import utils from "../lib/utils.js";
  13. import { ssl as logger } from "../logger.js";
  14. import certificateModel from "../models/certificate.js";
  15. import tokenModel from "../models/token.js";
  16. import userModel from "../models/user.js";
  17. import internalAuditLog from "./audit-log.js";
  18. import internalHost from "./host.js";
  19. import internalNginx from "./nginx.js";
  20. const letsencryptConfig = "/etc/letsencrypt.ini";
  21. const certbotCommand = "certbot";
  22. const omissions = () => {
  23. return ["is_deleted", "owner.is_deleted"];
  24. };
  25. const internalCertificate = {
  26. allowedSslFiles: [
  27. "certificate",
  28. "certificate_key",
  29. "intermediate_certificate",
  30. ],
  31. intervalTimeout: 1000 * 60 * 60, // 1 hour
  32. interval: null,
  33. intervalProcessing: false,
  34. renewBeforeExpirationBy: [30, "days"],
  35. initTimer: () => {
  36. logger.info("Let's Encrypt Renewal Timer initialized");
  37. internalCertificate.interval = setInterval(
  38. internalCertificate.processExpiringHosts,
  39. internalCertificate.intervalTimeout,
  40. );
  41. // And do this now as well
  42. internalCertificate.processExpiringHosts();
  43. },
  44. /**
  45. * Triggered by a timer, this will check for expiring hosts and renew their ssl certs if required
  46. */
  47. processExpiringHosts: () => {
  48. if (!internalCertificate.intervalProcessing) {
  49. internalCertificate.intervalProcessing = true;
  50. logger.info(
  51. `Renewing SSL certs expiring within ${internalCertificate.renewBeforeExpirationBy[0]} ${internalCertificate.renewBeforeExpirationBy[1]} ...`,
  52. );
  53. const expirationThreshold = moment()
  54. .add(
  55. internalCertificate.renewBeforeExpirationBy[0],
  56. internalCertificate.renewBeforeExpirationBy[1],
  57. )
  58. .format("YYYY-MM-DD HH:mm:ss");
  59. // Fetch all the letsencrypt certs from the db that will expire within the configured threshold
  60. certificateModel
  61. .query()
  62. .where("is_deleted", 0)
  63. .andWhere("provider", "letsencrypt")
  64. .andWhere("expires_on", "<", expirationThreshold)
  65. .then((certificates) => {
  66. if (!certificates || !certificates.length) {
  67. return null;
  68. }
  69. /**
  70. * Renews must be run sequentially or we'll get an error 'Another
  71. * instance of Certbot is already running.'
  72. */
  73. let sequence = Promise.resolve();
  74. certificates.forEach((certificate) => {
  75. sequence = sequence.then(() =>
  76. internalCertificate
  77. .renew(
  78. {
  79. can: () =>
  80. Promise.resolve({
  81. permission_visibility: "all",
  82. }),
  83. token: tokenModel(),
  84. },
  85. { id: certificate.id },
  86. )
  87. .catch((err) => {
  88. // Don't want to stop the train here, just log the error
  89. logger.error(err.message);
  90. }),
  91. );
  92. });
  93. return sequence;
  94. })
  95. .then(() => {
  96. logger.info("Completed SSL cert renew process");
  97. internalCertificate.intervalProcessing = false;
  98. })
  99. .catch((err) => {
  100. logger.error(err);
  101. internalCertificate.intervalProcessing = false;
  102. });
  103. }
  104. },
  105. /**
  106. * @param {Access} access
  107. * @param {Object} data
  108. * @returns {Promise}
  109. */
  110. create: async (access, data) => {
  111. await access.can("certificates:create", data);
  112. data.owner_user_id = access.token.getUserId(1);
  113. if (data.provider === "letsencrypt") {
  114. data.nice_name = data.domain_names.join(", ");
  115. }
  116. // this command really should clean up and delete the cert if it can't fully succeed
  117. const certificate = await certificateModel
  118. .query()
  119. .insertAndFetch(data)
  120. .then(utils.omitRow(omissions()));
  121. try {
  122. if (certificate.provider === "letsencrypt") {
  123. // Request a new Cert from LE. Let the fun begin.
  124. // 1. Find out any hosts that are using any of the hostnames in this cert
  125. // 2. Disable them in nginx temporarily
  126. // 3. Generate the LE config
  127. // 4. Request cert
  128. // 5. Remove LE config
  129. // 6. Re-instate previously disabled hosts
  130. // 1. Find out any hosts that are using any of the hostnames in this cert
  131. const inUseResult = await internalHost.getHostsWithDomains(
  132. certificate.domain_names,
  133. );
  134. // 2. Disable them in nginx temporarily
  135. await internalCertificate.disableInUseHosts(inUseResult);
  136. const user = await userModel
  137. .query()
  138. .where("is_deleted", 0)
  139. .andWhere("id", data.owner_user_id)
  140. .first();
  141. if (!user || !user.email) {
  142. throw new error.ValidationError(
  143. "A valid email address must be set on your user account to use Let's Encrypt",
  144. );
  145. }
  146. // With DNS challenge no config is needed, so skip 3 and 5.
  147. if (certificate.meta?.dns_challenge) {
  148. try {
  149. await internalNginx.reload();
  150. // 4. Request cert
  151. await internalCertificate.requestLetsEncryptSslWithDnsChallenge(
  152. certificate,
  153. user.email,
  154. );
  155. await internalNginx.reload();
  156. // 6. Re-instate previously disabled hosts
  157. await internalCertificate.enableInUseHosts(inUseResult);
  158. } catch (err) {
  159. // In the event of failure, revert things and throw err back
  160. await internalCertificate.enableInUseHosts(inUseResult);
  161. await internalNginx.reload();
  162. throw err;
  163. }
  164. } else {
  165. // 3. Generate the LE config
  166. try {
  167. await internalNginx.generateLetsEncryptRequestConfig(certificate);
  168. await internalNginx.reload();
  169. setTimeout(() => {}, 5000);
  170. // 4. Request cert
  171. await internalCertificate.requestLetsEncryptSsl(
  172. certificate,
  173. user.email,
  174. );
  175. // 5. Remove LE config
  176. await internalNginx.deleteLetsEncryptRequestConfig(certificate);
  177. await internalNginx.reload();
  178. // 6. Re-instate previously disabled hosts
  179. await internalCertificate.enableInUseHosts(inUseResult);
  180. } catch (err) {
  181. // In the event of failure, revert things and throw err back
  182. await internalNginx.deleteLetsEncryptRequestConfig(certificate);
  183. await internalCertificate.enableInUseHosts(inUseResult);
  184. await internalNginx.reload();
  185. throw err;
  186. }
  187. }
  188. // At this point, the letsencrypt cert should exist on disk.
  189. // Lets get the expiry date from the file and update the row silently
  190. try {
  191. const certInfo = await internalCertificate.getCertificateInfoFromFile(
  192. `${internalCertificate.getLiveCertPath(certificate.id)}/fullchain.pem`,
  193. );
  194. const savedRow = await certificateModel
  195. .query()
  196. .patchAndFetchById(certificate.id, {
  197. expires_on: moment(certInfo.dates.to, "X").format(
  198. "YYYY-MM-DD HH:mm:ss",
  199. ),
  200. })
  201. .then(utils.omitRow(omissions()));
  202. // Add cert data for audit log
  203. savedRow.meta = _.assign({}, savedRow.meta, {
  204. letsencrypt_certificate: certInfo,
  205. });
  206. return savedRow;
  207. } catch (err) {
  208. // Delete the certificate from the database if it was not created successfully
  209. await certificateModel.query().deleteById(certificate.id);
  210. throw err;
  211. }
  212. }
  213. } catch (err) {
  214. // Delete the certificate here. This is a hard delete, since it never existed properly
  215. await certificateModel.query().deleteById(certificate.id);
  216. throw err;
  217. }
  218. data.meta = _.assign({}, data.meta || {}, certificate.meta);
  219. // Add to audit log
  220. await internalAuditLog.add(access, {
  221. action: "created",
  222. object_type: "certificate",
  223. object_id: certificate.id,
  224. meta: data,
  225. });
  226. return certificate;
  227. },
  228. /**
  229. * @param {Access} access
  230. * @param {Object} data
  231. * @param {Number} data.id
  232. * @param {String} [data.email]
  233. * @param {String} [data.name]
  234. * @return {Promise}
  235. */
  236. update: async (access, data) => {
  237. await access.can("certificates:update", data.id);
  238. const row = await internalCertificate.get(access, { id: data.id });
  239. if (row.id !== data.id) {
  240. // Sanity check that something crazy hasn't happened
  241. throw new error.InternalValidationError(
  242. `Certificate could not be updated, IDs do not match: ${row.id} !== ${data.id}`,
  243. );
  244. }
  245. const savedRow = await certificateModel
  246. .query()
  247. .patchAndFetchById(row.id, data)
  248. .then(utils.omitRow(omissions()));
  249. savedRow.meta = internalCertificate.cleanMeta(savedRow.meta);
  250. data.meta = internalCertificate.cleanMeta(data.meta);
  251. // Add row.nice_name for custom certs
  252. if (savedRow.provider === "other") {
  253. data.nice_name = savedRow.nice_name;
  254. }
  255. // Add to audit log
  256. await internalAuditLog.add(access, {
  257. action: "updated",
  258. object_type: "certificate",
  259. object_id: row.id,
  260. meta: _.omit(data, ["expires_on"]), // this prevents json circular reference because expires_on might be raw
  261. });
  262. return savedRow;
  263. },
  264. /**
  265. * @param {Access} access
  266. * @param {Object} data
  267. * @param {Number} data.id
  268. * @param {Array} [data.expand]
  269. * @param {Array} [data.omit]
  270. * @return {Promise}
  271. */
  272. get: async (access, data) => {
  273. const accessData = await access.can("certificates:get", data.id);
  274. const query = certificateModel
  275. .query()
  276. .where("is_deleted", 0)
  277. .andWhere("id", data.id)
  278. .allowGraph("[owner]")
  279. .allowGraph("[proxy_hosts]")
  280. .allowGraph("[redirection_hosts]")
  281. .allowGraph("[dead_hosts]")
  282. .first();
  283. if (accessData.permission_visibility !== "all") {
  284. query.andWhere("owner_user_id", access.token.getUserId(1));
  285. }
  286. if (typeof data.expand !== "undefined" && data.expand !== null) {
  287. query.withGraphFetched(`[${data.expand.join(", ")}]`);
  288. }
  289. const row = await query.then(utils.omitRow(omissions()));
  290. if (!row || !row.id) {
  291. throw new error.ItemNotFoundError(data.id);
  292. }
  293. // Custom omissions
  294. if (typeof data.omit !== "undefined" && data.omit !== null) {
  295. return _.omit(row, data.omit);
  296. }
  297. return row;
  298. },
  299. /**
  300. * @param {Access} access
  301. * @param {Object} data
  302. * @param {Number} data.id
  303. * @returns {Promise}
  304. */
  305. download: async (access, data) => {
  306. await access.can("certificates:get", data);
  307. const certificate = await internalCertificate.get(access, data);
  308. if (certificate.provider === "letsencrypt") {
  309. const zipDirectory = internalCertificate.getLiveCertPath(data.id);
  310. if (!fs.existsSync(zipDirectory)) {
  311. throw new error.ItemNotFoundError(
  312. `Certificate ${certificate.nice_name} does not exists`,
  313. );
  314. }
  315. const certFiles = fs
  316. .readdirSync(zipDirectory)
  317. .filter((fn) => fn.endsWith(".pem"))
  318. .map((fn) => fs.realpathSync(path.join(zipDirectory, fn)));
  319. const downloadName = `npm-${data.id}-${Date.now()}.zip`;
  320. const opName = `/tmp/${downloadName}`;
  321. await internalCertificate.zipFiles(certFiles, opName);
  322. logger.debug("zip completed : ", opName);
  323. return {
  324. fileName: opName,
  325. };
  326. }
  327. throw new error.ValidationError(
  328. "Only Let'sEncrypt certificates can be downloaded",
  329. );
  330. },
  331. /**
  332. * @param {String} source
  333. * @param {String} out
  334. * @returns {Promise}
  335. */
  336. zipFiles: async (source, out) => {
  337. const archive = archiver("zip", { zlib: { level: 9 } });
  338. const stream = fs.createWriteStream(out);
  339. return new Promise((resolve, reject) => {
  340. source.map((fl) => {
  341. const fileName = path.basename(fl);
  342. logger.debug(fl, "added to certificate zip");
  343. archive.file(fl, { name: fileName });
  344. return true;
  345. });
  346. archive.on("error", (err) => reject(err)).pipe(stream);
  347. stream.on("close", () => resolve());
  348. archive.finalize();
  349. });
  350. },
  351. /**
  352. * @param {Access} access
  353. * @param {Object} data
  354. * @param {Number} data.id
  355. * @param {String} [data.reason]
  356. * @returns {Promise}
  357. */
  358. delete: async (access, data) => {
  359. await access.can("certificates:delete", data.id);
  360. const row = await internalCertificate.get(access, { id: data.id });
  361. if (!row || !row.id) {
  362. throw new error.ItemNotFoundError(data.id);
  363. }
  364. await certificateModel.query().where("id", row.id).patch({
  365. is_deleted: 1,
  366. });
  367. // Add to audit log
  368. row.meta = internalCertificate.cleanMeta(row.meta);
  369. await internalAuditLog.add(access, {
  370. action: "deleted",
  371. object_type: "certificate",
  372. object_id: row.id,
  373. meta: _.omit(row, omissions()),
  374. });
  375. if (row.provider === "letsencrypt") {
  376. // Revoke the cert
  377. await internalCertificate.revokeLetsEncryptSsl(row);
  378. }
  379. return true;
  380. },
  381. /**
  382. * All Certs
  383. *
  384. * @param {Access} access
  385. * @param {Array} [expand]
  386. * @param {String} [searchQuery]
  387. * @returns {Promise}
  388. */
  389. getAll: async (access, expand, searchQuery) => {
  390. const accessData = await access.can("certificates:list");
  391. const query = certificateModel
  392. .query()
  393. .where("is_deleted", 0)
  394. .groupBy("id")
  395. .allowGraph("[owner,proxy_hosts,redirection_hosts,dead_hosts]")
  396. .orderBy("nice_name", "ASC");
  397. if (accessData.permission_visibility !== "all") {
  398. query.andWhere("owner_user_id", access.token.getUserId(1));
  399. }
  400. // Query is used for searching
  401. if (typeof searchQuery === "string") {
  402. query.where(function () {
  403. this.where("nice_name", "like", `%${searchQuery}%`);
  404. });
  405. }
  406. if (typeof expand !== "undefined" && expand !== null) {
  407. query.withGraphFetched(`[${expand.join(", ")}]`);
  408. }
  409. return await query.then(utils.omitRows(omissions()));
  410. },
  411. /**
  412. * Report use
  413. *
  414. * @param {Number} userId
  415. * @param {String} visibility
  416. * @returns {Promise}
  417. */
  418. getCount: async (userId, visibility) => {
  419. const query = certificateModel
  420. .query()
  421. .count("id as count")
  422. .where("is_deleted", 0);
  423. if (visibility !== "all") {
  424. query.andWhere("owner_user_id", userId);
  425. }
  426. const row = await query.first();
  427. return Number.parseInt(row.count, 10);
  428. },
  429. /**
  430. * @param {Object} certificate
  431. * @returns {Promise}
  432. */
  433. writeCustomCert: async (certificate) => {
  434. logger.info("Writing Custom Certificate:", certificate);
  435. const dir = `/data/custom_ssl/npm-${certificate.id}`;
  436. return new Promise((resolve, reject) => {
  437. if (certificate.provider === "letsencrypt") {
  438. reject(new Error("Refusing to write letsencrypt certs here"));
  439. return;
  440. }
  441. let certData = certificate.meta.certificate;
  442. if (typeof certificate.meta.intermediate_certificate !== "undefined") {
  443. certData = `${certData}\n${certificate.meta.intermediate_certificate}`;
  444. }
  445. try {
  446. if (!fs.existsSync(dir)) {
  447. fs.mkdirSync(dir);
  448. }
  449. } catch (err) {
  450. reject(err);
  451. return;
  452. }
  453. fs.writeFile(`${dir}/fullchain.pem`, certData, (err) => {
  454. if (err) {
  455. reject(err);
  456. } else {
  457. resolve();
  458. }
  459. });
  460. }).then(() => {
  461. return new Promise((resolve, reject) => {
  462. fs.writeFile(
  463. `${dir}/privkey.pem`,
  464. certificate.meta.certificate_key,
  465. (err) => {
  466. if (err) {
  467. reject(err);
  468. } else {
  469. resolve();
  470. }
  471. },
  472. );
  473. });
  474. });
  475. },
  476. /**
  477. * @param {Access} access
  478. * @param {Object} data
  479. * @param {Array} data.domain_names
  480. * @returns {Promise}
  481. */
  482. createQuickCertificate: async (access, data) => {
  483. return await internalCertificate.create(access, {
  484. provider: "letsencrypt",
  485. domain_names: data.domain_names,
  486. meta: data.meta,
  487. });
  488. },
  489. /**
  490. * Validates that the certs provided are good.
  491. * No access required here, nothing is changed or stored.
  492. *
  493. * @param {Object} data
  494. * @param {Object} data.files
  495. * @returns {Promise}
  496. */
  497. validate: (data) => {
  498. // Put file contents into an object
  499. const files = {};
  500. _.map(data.files, (file, name) => {
  501. if (internalCertificate.allowedSslFiles.indexOf(name) !== -1) {
  502. files[name] = file.data.toString();
  503. }
  504. });
  505. // For each file, create a temp file and write the contents to it
  506. // Then test it depending on the file type
  507. const promises = [];
  508. _.map(files, (content, type) => {
  509. promises.push(
  510. new Promise((resolve) => {
  511. if (type === "certificate_key") {
  512. resolve(internalCertificate.checkPrivateKey(content));
  513. } else {
  514. // this should handle `certificate` and intermediate certificate
  515. resolve(internalCertificate.getCertificateInfo(content, true));
  516. }
  517. }).then((res) => {
  518. return { [type]: res };
  519. }),
  520. );
  521. });
  522. return Promise.all(promises).then((files) => {
  523. let data = {};
  524. _.each(files, (file) => {
  525. data = _.assign({}, data, file);
  526. });
  527. return data;
  528. });
  529. },
  530. /**
  531. * @param {Access} access
  532. * @param {Object} data
  533. * @param {Number} data.id
  534. * @param {Object} data.files
  535. * @returns {Promise}
  536. */
  537. upload: async (access, data) => {
  538. const row = await internalCertificate.get(access, { id: data.id });
  539. if (row.provider !== "other") {
  540. throw new error.ValidationError(
  541. "Cannot upload certificates for this type of provider",
  542. );
  543. }
  544. const validations = await internalCertificate.validate(data);
  545. if (typeof validations.certificate === "undefined") {
  546. throw new error.ValidationError("Certificate file was not provided");
  547. }
  548. _.map(data.files, (file, name) => {
  549. if (internalCertificate.allowedSslFiles.indexOf(name) !== -1) {
  550. row.meta[name] = file.data.toString();
  551. }
  552. });
  553. const certificate = await internalCertificate.update(access, {
  554. id: data.id,
  555. expires_on: moment(validations.certificate.dates.to, "X").format(
  556. "YYYY-MM-DD HH:mm:ss",
  557. ),
  558. domain_names: [validations.certificate.cn],
  559. meta: _.clone(row.meta), // Prevent the update method from changing this value that we'll use later
  560. });
  561. certificate.meta = row.meta;
  562. await internalCertificate.writeCustomCert(certificate);
  563. return _.pick(row.meta, internalCertificate.allowedSslFiles);
  564. },
  565. /**
  566. * Uses the openssl command to validate the private key.
  567. * It will save the file to disk first, then run commands on it, then delete the file.
  568. *
  569. * @param {String} privateKey This is the entire key contents as a string
  570. */
  571. checkPrivateKey: async (privateKey) => {
  572. const filepath = await tempWrite(privateKey, "/tmp");
  573. const failTimeout = setTimeout(() => {
  574. throw new error.ValidationError(
  575. "Result Validation Error: Validation timed out. This could be due to the key being passphrase-protected.",
  576. );
  577. }, 10000);
  578. try {
  579. const result = await utils.exec(
  580. `openssl pkey -in ${filepath} -check -noout 2>&1 `,
  581. );
  582. clearTimeout(failTimeout);
  583. if (!result.toLowerCase().includes("key is valid")) {
  584. throw new error.ValidationError(`Result Validation Error: ${result}`);
  585. }
  586. fs.unlinkSync(filepath);
  587. return true;
  588. } catch (err) {
  589. clearTimeout(failTimeout);
  590. fs.unlinkSync(filepath);
  591. throw new error.ValidationError(
  592. `Certificate Key is not valid (${err.message})`,
  593. err,
  594. );
  595. }
  596. },
  597. /**
  598. * Uses the openssl command to both validate and get info out of the certificate.
  599. * It will save the file to disk first, then run commands on it, then delete the file.
  600. *
  601. * @param {String} certificate This is the entire cert contents as a string
  602. * @param {Boolean} [throwExpired] Throw when the certificate is out of date
  603. */
  604. getCertificateInfo: async (certificate, throwExpired) => {
  605. try {
  606. const filepath = await tempWrite(certificate, "/tmp");
  607. const certData = await internalCertificate.getCertificateInfoFromFile(
  608. filepath,
  609. throwExpired,
  610. );
  611. fs.unlinkSync(filepath);
  612. return certData;
  613. } catch (err) {
  614. fs.unlinkSync(filepath);
  615. throw err;
  616. }
  617. },
  618. /**
  619. * Uses the openssl command to both validate and get info out of the certificate.
  620. * It will save the file to disk first, then run commands on it, then delete the file.
  621. *
  622. * @param {String} certificateFile The file location on disk
  623. * @param {Boolean} [throw_expired] Throw when the certificate is out of date
  624. */
  625. getCertificateInfoFromFile: async (certificateFile, throw_expired) => {
  626. const certData = {};
  627. try {
  628. const result = await utils.execFile("openssl", [
  629. "x509",
  630. "-in",
  631. certificateFile,
  632. "-subject",
  633. "-noout",
  634. ]);
  635. // Examples:
  636. // subject=CN = *.jc21.com
  637. // subject=CN = something.example.com
  638. const regex = /(?:subject=)?[^=]+=\s+(\S+)/gim;
  639. const match = regex.exec(result);
  640. if (match && typeof match[1] !== "undefined") {
  641. certData.cn = match[1];
  642. }
  643. const result2 = await utils.execFile("openssl", [
  644. "x509",
  645. "-in",
  646. certificateFile,
  647. "-issuer",
  648. "-noout",
  649. ]);
  650. // Examples:
  651. // issuer=C = US, O = Let's Encrypt, CN = Let's Encrypt Authority X3
  652. // issuer=C = US, O = Let's Encrypt, CN = E5
  653. // issuer=O = NginxProxyManager, CN = NginxProxyManager Intermediate CA","O = NginxProxyManager, CN = NginxProxyManager Intermediate CA
  654. const regex2 = /^(?:issuer=)?(.*)$/gim;
  655. const match2 = regex2.exec(result2);
  656. if (match2 && typeof match2[1] !== "undefined") {
  657. certData.issuer = match2[1];
  658. }
  659. const result3 = await utils.execFile("openssl", [
  660. "x509",
  661. "-in",
  662. certificateFile,
  663. "-dates",
  664. "-noout",
  665. ]);
  666. // notBefore=Jul 14 04:04:29 2018 GMT
  667. // notAfter=Oct 12 04:04:29 2018 GMT
  668. let validFrom = null;
  669. let validTo = null;
  670. const lines = result3.split("\n");
  671. lines.map((str) => {
  672. const regex = /^(\S+)=(.*)$/gim;
  673. const match = regex.exec(str.trim());
  674. if (match && typeof match[2] !== "undefined") {
  675. const date = Number.parseInt(
  676. moment(match[2], "MMM DD HH:mm:ss YYYY z").format("X"),
  677. 10,
  678. );
  679. if (match[1].toLowerCase() === "notbefore") {
  680. validFrom = date;
  681. } else if (match[1].toLowerCase() === "notafter") {
  682. validTo = date;
  683. }
  684. }
  685. return true;
  686. });
  687. if (!validFrom || !validTo) {
  688. throw new error.ValidationError(
  689. `Could not determine dates from certificate: ${result}`,
  690. );
  691. }
  692. if (
  693. throw_expired &&
  694. validTo < Number.parseInt(moment().format("X"), 10)
  695. ) {
  696. throw new error.ValidationError("Certificate has expired");
  697. }
  698. certData.dates = {
  699. from: validFrom,
  700. to: validTo,
  701. };
  702. return certData;
  703. } catch (err) {
  704. throw new error.ValidationError(
  705. `Certificate is not valid (${err.message})`,
  706. err,
  707. );
  708. }
  709. },
  710. /**
  711. * Cleans the ssl keys from the meta object and sets them to "true"
  712. *
  713. * @param {Object} meta
  714. * @param {Boolean} [remove]
  715. * @returns {Object}
  716. */
  717. cleanMeta: (meta, remove) => {
  718. internalCertificate.allowedSslFiles.map((key) => {
  719. if (typeof meta[key] !== "undefined" && meta[key]) {
  720. if (remove) {
  721. delete meta[key];
  722. } else {
  723. meta[key] = true;
  724. }
  725. }
  726. return true;
  727. });
  728. return meta;
  729. },
  730. /**
  731. * Request a certificate using the http challenge
  732. * @param {Object} certificate the certificate row
  733. * @param {String} email the email address to use for registration
  734. * @returns {Promise}
  735. */
  736. requestLetsEncryptSsl: async (certificate, email) => {
  737. logger.info(
  738. `Requesting LetsEncrypt certificates for Cert #${certificate.id}: ${certificate.domain_names.join(", ")}`,
  739. );
  740. const args = [
  741. "certonly",
  742. "--config",
  743. letsencryptConfig,
  744. "--work-dir",
  745. "/tmp/letsencrypt-lib",
  746. "--logs-dir",
  747. "/tmp/letsencrypt-log",
  748. "--cert-name",
  749. `npm-${certificate.id}`,
  750. "--agree-tos",
  751. "--authenticator",
  752. "webroot",
  753. "--email",
  754. email,
  755. "--preferred-challenges",
  756. "dns,http",
  757. "--domains",
  758. certificate.domain_names.join(","),
  759. ];
  760. const adds = internalCertificate.getAdditionalCertbotArgs(certificate.id);
  761. args.push(...adds.args);
  762. logger.info(`Command: ${certbotCommand} ${args ? args.join(" ") : ""}`);
  763. const result = await utils.execFile(certbotCommand, args, adds.opts);
  764. logger.success(result);
  765. return result;
  766. },
  767. /**
  768. * @param {Object} certificate the certificate row
  769. * @param {String} email the email address to use for registration
  770. * @returns {Promise}
  771. */
  772. requestLetsEncryptSslWithDnsChallenge: async (certificate, email) => {
  773. await installPlugin(certificate.meta.dns_provider);
  774. const dnsPlugin = dnsPlugins[certificate.meta.dns_provider];
  775. logger.info(
  776. `Requesting LetsEncrypt certificates via ${dnsPlugin.name} for Cert #${certificate.id}: ${certificate.domain_names.join(", ")}`,
  777. );
  778. const credentialsLocation = `/etc/letsencrypt/credentials/credentials-${certificate.id}`;
  779. fs.mkdirSync("/etc/letsencrypt/credentials", { recursive: true });
  780. fs.writeFileSync(
  781. credentialsLocation,
  782. certificate.meta.dns_provider_credentials,
  783. { mode: 0o600 },
  784. );
  785. // Whether the plugin has a --<name>-credentials argument
  786. const hasConfigArg = certificate.meta.dns_provider !== "route53";
  787. const args = [
  788. "certonly",
  789. "--config",
  790. letsencryptConfig,
  791. "--work-dir",
  792. "/tmp/letsencrypt-lib",
  793. "--logs-dir",
  794. "/tmp/letsencrypt-log",
  795. "--cert-name",
  796. `npm-${certificate.id}`,
  797. "--agree-tos",
  798. "--email",
  799. email,
  800. "--domains",
  801. certificate.domain_names.join(","),
  802. "--authenticator",
  803. dnsPlugin.full_plugin_name,
  804. ];
  805. if (hasConfigArg) {
  806. args.push(
  807. `--${dnsPlugin.full_plugin_name}-credentials`,
  808. credentialsLocation,
  809. );
  810. }
  811. if (certificate.meta.propagation_seconds !== undefined) {
  812. args.push(
  813. `--${dnsPlugin.full_plugin_name}-propagation-seconds`,
  814. certificate.meta.propagation_seconds.toString(),
  815. );
  816. }
  817. const adds = internalCertificate.getAdditionalCertbotArgs(
  818. certificate.id,
  819. certificate.meta.dns_provider,
  820. );
  821. args.push(...adds.args);
  822. logger.info(`Command: ${certbotCommand} ${args ? args.join(" ") : ""}`);
  823. try {
  824. const result = await utils.execFile(certbotCommand, args, adds.opts);
  825. logger.info(result);
  826. return result;
  827. } catch (err) {
  828. // Don't fail if file does not exist, so no need for action in the callback
  829. fs.unlink(credentialsLocation, () => {});
  830. throw err;
  831. }
  832. },
  833. /**
  834. * @param {Access} access
  835. * @param {Object} data
  836. * @param {Number} data.id
  837. * @returns {Promise}
  838. */
  839. renew: async (access, data) => {
  840. await access.can("certificates:update", data);
  841. const certificate = await internalCertificate.get(access, data);
  842. if (certificate.provider === "letsencrypt") {
  843. const renewMethod = certificate.meta.dns_challenge
  844. ? internalCertificate.renewLetsEncryptSslWithDnsChallenge
  845. : internalCertificate.renewLetsEncryptSsl;
  846. await renewMethod(certificate);
  847. const certInfo = await internalCertificate.getCertificateInfoFromFile(
  848. `${internalCertificate.getLiveCertPath(certificate.id)}/fullchain.pem`,
  849. );
  850. const updatedCertificate = await certificateModel
  851. .query()
  852. .patchAndFetchById(certificate.id, {
  853. expires_on: moment(certInfo.dates.to, "X").format(
  854. "YYYY-MM-DD HH:mm:ss",
  855. ),
  856. });
  857. // Add to audit log
  858. await internalAuditLog.add(access, {
  859. action: "renewed",
  860. object_type: "certificate",
  861. object_id: updatedCertificate.id,
  862. meta: updatedCertificate,
  863. });
  864. } else {
  865. throw new error.ValidationError(
  866. "Only Let'sEncrypt certificates can be renewed",
  867. );
  868. }
  869. },
  870. /**
  871. * @param {Object} certificate the certificate row
  872. * @returns {Promise}
  873. */
  874. renewLetsEncryptSsl: async (certificate) => {
  875. logger.info(
  876. `Renewing LetsEncrypt certificates for Cert #${certificate.id}: ${certificate.domain_names.join(", ")}`,
  877. );
  878. const args = [
  879. "renew",
  880. "--force-renewal",
  881. "--config",
  882. letsencryptConfig,
  883. "--work-dir",
  884. "/tmp/letsencrypt-lib",
  885. "--logs-dir",
  886. "/tmp/letsencrypt-log",
  887. "--cert-name",
  888. `npm-${certificate.id}`,
  889. "--preferred-challenges",
  890. "dns,http",
  891. "--no-random-sleep-on-renew",
  892. "--disable-hook-validation",
  893. ];
  894. const adds = internalCertificate.getAdditionalCertbotArgs(
  895. certificate.id,
  896. certificate.meta.dns_provider,
  897. );
  898. args.push(...adds.args);
  899. logger.info(`Command: ${certbotCommand} ${args ? args.join(" ") : ""}`);
  900. const result = await utils.execFile(certbotCommand, args, adds.opts);
  901. logger.info(result);
  902. return result;
  903. },
  904. /**
  905. * @param {Object} certificate the certificate row
  906. * @returns {Promise}
  907. */
  908. renewLetsEncryptSslWithDnsChallenge: async (certificate) => {
  909. const dnsPlugin = dnsPlugins[certificate.meta.dns_provider];
  910. if (!dnsPlugin) {
  911. throw Error(`Unknown DNS provider '${certificate.meta.dns_provider}'`);
  912. }
  913. logger.info(
  914. `Renewing LetsEncrypt certificates via ${dnsPlugin.name} for Cert #${certificate.id}: ${certificate.domain_names.join(", ")}`,
  915. );
  916. const args = [
  917. "renew",
  918. "--force-renewal",
  919. "--config",
  920. letsencryptConfig,
  921. "--work-dir",
  922. "/tmp/letsencrypt-lib",
  923. "--logs-dir",
  924. "/tmp/letsencrypt-log",
  925. "--cert-name",
  926. `npm-${certificate.id}`,
  927. "--disable-hook-validation",
  928. "--no-random-sleep-on-renew",
  929. ];
  930. const adds = internalCertificate.getAdditionalCertbotArgs(
  931. certificate.id,
  932. certificate.meta.dns_provider,
  933. );
  934. args.push(...adds.args);
  935. logger.info(`Command: ${certbotCommand} ${args ? args.join(" ") : ""}`);
  936. const result = await utils.execFile(certbotCommand, args, adds.opts);
  937. logger.info(result);
  938. return result;
  939. },
  940. /**
  941. * @param {Object} certificate the certificate row
  942. * @param {Boolean} [throwErrors]
  943. * @returns {Promise}
  944. */
  945. revokeLetsEncryptSsl: async (certificate, throwErrors) => {
  946. logger.info(
  947. `Revoking LetsEncrypt certificates for Cert #${certificate.id}: ${certificate.domain_names.join(", ")}`,
  948. );
  949. const args = [
  950. "revoke",
  951. "--config",
  952. letsencryptConfig,
  953. "--work-dir",
  954. "/tmp/letsencrypt-lib",
  955. "--logs-dir",
  956. "/tmp/letsencrypt-log",
  957. "--cert-path",
  958. `${internalCertificate.getLiveCertPath(certificate.id)}/fullchain.pem`,
  959. "--delete-after-revoke",
  960. ];
  961. const adds = internalCertificate.getAdditionalCertbotArgs(certificate.id);
  962. args.push(...adds.args);
  963. logger.info(`Command: ${certbotCommand} ${args ? args.join(" ") : ""}`);
  964. try {
  965. const result = await utils.execFile(certbotCommand, args, adds.opts);
  966. await utils.exec(
  967. `rm -f '/etc/letsencrypt/credentials/credentials-${certificate.id}' || true`,
  968. );
  969. logger.info(result);
  970. return result;
  971. } catch (err) {
  972. logger.error(err.message);
  973. if (throwErrors) {
  974. throw err;
  975. }
  976. }
  977. },
  978. /**
  979. * @param {Object} certificate
  980. * @returns {Boolean}
  981. */
  982. hasLetsEncryptSslCerts: (certificate) => {
  983. const letsencryptPath = internalCertificate.getLiveCertPath(certificate.id);
  984. return (
  985. fs.existsSync(`${letsencryptPath}/fullchain.pem`) &&
  986. fs.existsSync(`${letsencryptPath}/privkey.pem`)
  987. );
  988. },
  989. /**
  990. * @param {Object} inUseResult
  991. * @param {Number} inUseResult.total_count
  992. * @param {Array} inUseResult.proxy_hosts
  993. * @param {Array} inUseResult.redirection_hosts
  994. * @param {Array} inUseResult.dead_hosts
  995. * @returns {Promise}
  996. */
  997. disableInUseHosts: async (inUseResult) => {
  998. if (inUseResult?.total_count) {
  999. if (inUseResult?.proxy_hosts.length) {
  1000. await internalNginx.bulkDeleteConfigs(
  1001. "proxy_host",
  1002. inUseResult.proxy_hosts,
  1003. );
  1004. }
  1005. if (inUseResult?.redirection_hosts.length) {
  1006. await internalNginx.bulkDeleteConfigs(
  1007. "redirection_host",
  1008. inUseResult.redirection_hosts,
  1009. );
  1010. }
  1011. if (inUseResult?.dead_hosts.length) {
  1012. await internalNginx.bulkDeleteConfigs(
  1013. "dead_host",
  1014. inUseResult.dead_hosts,
  1015. );
  1016. }
  1017. }
  1018. },
  1019. /**
  1020. * @param {Object} inUseResult
  1021. * @param {Number} inUseResult.total_count
  1022. * @param {Array} inUseResult.proxy_hosts
  1023. * @param {Array} inUseResult.redirection_hosts
  1024. * @param {Array} inUseResult.dead_hosts
  1025. * @returns {Promise}
  1026. */
  1027. enableInUseHosts: async (inUseResult) => {
  1028. if (inUseResult.total_count) {
  1029. if (inUseResult.proxy_hosts.length) {
  1030. await internalNginx.bulkGenerateConfigs(
  1031. "proxy_host",
  1032. inUseResult.proxy_hosts,
  1033. );
  1034. }
  1035. if (inUseResult.redirection_hosts.length) {
  1036. await internalNginx.bulkGenerateConfigs(
  1037. "redirection_host",
  1038. inUseResult.redirection_hosts,
  1039. );
  1040. }
  1041. if (inUseResult.dead_hosts.length) {
  1042. await internalNginx.bulkGenerateConfigs(
  1043. "dead_host",
  1044. inUseResult.dead_hosts,
  1045. );
  1046. }
  1047. }
  1048. },
  1049. /**
  1050. *
  1051. * @param {Object} payload
  1052. * @param {string[]} payload.domains
  1053. * @returns
  1054. */
  1055. testHttpsChallenge: async (access, payload) => {
  1056. await access.can("certificates:list");
  1057. // Create a test challenge file
  1058. const testChallengeDir =
  1059. "/data/letsencrypt-acme-challenge/.well-known/acme-challenge";
  1060. const testChallengeFile = `${testChallengeDir}/test-challenge`;
  1061. fs.mkdirSync(testChallengeDir, { recursive: true });
  1062. fs.writeFileSync(testChallengeFile, "Success", { encoding: "utf8" });
  1063. const results = {};
  1064. for (const domain of payload.domains) {
  1065. results[domain] = await internalCertificate.performTestForDomain(domain);
  1066. }
  1067. // Remove the test challenge file
  1068. fs.unlinkSync(testChallengeFile);
  1069. return results;
  1070. },
  1071. performTestForDomain: async (domain) => {
  1072. logger.info(`Testing http challenge for ${domain}`);
  1073. const url = `http://${domain}/.well-known/acme-challenge/test-challenge`;
  1074. const formBody = `method=G&url=${encodeURI(url)}&bodytype=T&requestbody=&headername=User-Agent&headervalue=None&locationid=1&ch=false&cc=false`;
  1075. const options = {
  1076. method: "POST",
  1077. headers: {
  1078. "User-Agent": "Mozilla/5.0",
  1079. "Content-Type": "application/x-www-form-urlencoded",
  1080. "Content-Length": Buffer.byteLength(formBody),
  1081. },
  1082. };
  1083. const result = await new Promise((resolve) => {
  1084. const req = https.request(
  1085. "https://www.site24x7.com/tools/restapi-tester",
  1086. options,
  1087. (res) => {
  1088. let responseBody = "";
  1089. res.on("data", (chunk) => {
  1090. responseBody = responseBody + chunk;
  1091. });
  1092. res.on("end", () => {
  1093. try {
  1094. const parsedBody = JSON.parse(`${responseBody}`);
  1095. if (res.statusCode !== 200) {
  1096. logger.warn(
  1097. `Failed to test HTTP challenge for domain ${domain} because HTTP status code ${res.statusCode} was returned: ${parsedBody.message}`,
  1098. );
  1099. resolve(undefined);
  1100. } else {
  1101. resolve(parsedBody);
  1102. }
  1103. } catch (err) {
  1104. if (res.statusCode !== 200) {
  1105. logger.warn(
  1106. `Failed to test HTTP challenge for domain ${domain} because HTTP status code ${res.statusCode} was returned`,
  1107. );
  1108. } else {
  1109. logger.warn(
  1110. `Failed to test HTTP challenge for domain ${domain} because response failed to be parsed: ${err.message}`,
  1111. );
  1112. }
  1113. resolve(undefined);
  1114. }
  1115. });
  1116. },
  1117. );
  1118. // Make sure to write the request body.
  1119. req.write(formBody);
  1120. req.end();
  1121. req.on("error", (e) => {
  1122. logger.warn(`Failed to test HTTP challenge for domain ${domain}`, e);
  1123. resolve(undefined);
  1124. });
  1125. });
  1126. if (!result) {
  1127. // Some error occurred while trying to get the data
  1128. return "failed";
  1129. }
  1130. if (result.error) {
  1131. logger.info(
  1132. `HTTP challenge test failed for domain ${domain} because error was returned: ${result.error.msg}`,
  1133. );
  1134. return `other:${result.error.msg}`;
  1135. }
  1136. if (
  1137. `${result.responsecode}` === "200" &&
  1138. result.htmlresponse === "Success"
  1139. ) {
  1140. // Server exists and has responded with the correct data
  1141. return "ok";
  1142. }
  1143. if (`${result.responsecode}` === "200") {
  1144. // Server exists but has responded with wrong data
  1145. logger.info(
  1146. `HTTP challenge test failed for domain ${domain} because of invalid returned data:`,
  1147. result.htmlresponse,
  1148. );
  1149. return "wrong-data";
  1150. }
  1151. if (`${result.responsecode}` === "404") {
  1152. // Server exists but responded with a 404
  1153. logger.info(
  1154. `HTTP challenge test failed for domain ${domain} because code 404 was returned`,
  1155. );
  1156. return "404";
  1157. }
  1158. if (
  1159. `${result.responsecode}` === "0" ||
  1160. (typeof result.reason === "string" &&
  1161. result.reason.toLowerCase() === "host unavailable")
  1162. ) {
  1163. // Server does not exist at domain
  1164. logger.info(
  1165. `HTTP challenge test failed for domain ${domain} the host was not found`,
  1166. );
  1167. return "no-host";
  1168. }
  1169. // Other errors
  1170. logger.info(
  1171. `HTTP challenge test failed for domain ${domain} because code ${result.responsecode} was returned`,
  1172. );
  1173. return `other:${result.responsecode}`;
  1174. },
  1175. getAdditionalCertbotArgs: (certificate_id, dns_provider) => {
  1176. const args = [];
  1177. if (useLetsencryptServer() !== null) {
  1178. args.push("--server", useLetsencryptServer());
  1179. }
  1180. if (useLetsencryptStaging() && useLetsencryptServer() === null) {
  1181. args.push("--staging");
  1182. }
  1183. // For route53, add the credentials file as an environment variable,
  1184. // inheriting the process env
  1185. const opts = {};
  1186. if (certificate_id && dns_provider === "route53") {
  1187. opts.env = process.env;
  1188. opts.env.AWS_CONFIG_FILE = `/etc/letsencrypt/credentials/credentials-${certificate_id}`;
  1189. }
  1190. if (dns_provider === "duckdns") {
  1191. args.push("--dns-duckdns-no-txt-restore");
  1192. }
  1193. return { args: args, opts: opts };
  1194. },
  1195. getLiveCertPath: (certificateId) => {
  1196. return `/etc/letsencrypt/live/npm-${certificateId}`;
  1197. },
  1198. };
  1199. export default internalCertificate;