certificate.js 36 KB

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