certificate.js 36 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351
  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. return updatedCertificate;
  869. } else {
  870. throw new error.ValidationError(
  871. "Only Let'sEncrypt certificates can be renewed",
  872. );
  873. }
  874. },
  875. /**
  876. * @param {Object} certificate the certificate row
  877. * @returns {Promise}
  878. */
  879. renewLetsEncryptSsl: async (certificate) => {
  880. logger.info(
  881. `Renewing LetsEncrypt certificates for Cert #${certificate.id}: ${certificate.domain_names.join(", ")}`,
  882. );
  883. const args = [
  884. "renew",
  885. "--force-renewal",
  886. "--config",
  887. letsencryptConfig,
  888. "--work-dir",
  889. certbotWorkDir,
  890. "--logs-dir",
  891. certbotLogsDir,
  892. "--cert-name",
  893. `npm-${certificate.id}`,
  894. "--preferred-challenges",
  895. "http",
  896. "--no-random-sleep-on-renew",
  897. "--disable-hook-validation",
  898. ];
  899. const adds = internalCertificate.getAdditionalCertbotArgs(
  900. certificate.id,
  901. certificate.meta.dns_provider,
  902. );
  903. args.push(...adds.args);
  904. logger.info(`Command: ${certbotCommand} ${args ? args.join(" ") : ""}`);
  905. const result = await utils.execFile(certbotCommand, args, adds.opts);
  906. logger.info(result);
  907. return result;
  908. },
  909. /**
  910. * @param {Object} certificate the certificate row
  911. * @returns {Promise}
  912. */
  913. renewLetsEncryptSslWithDnsChallenge: async (certificate) => {
  914. const dnsPlugin = dnsPlugins[certificate.meta.dns_provider];
  915. if (!dnsPlugin) {
  916. throw Error(`Unknown DNS provider '${certificate.meta.dns_provider}'`);
  917. }
  918. logger.info(
  919. `Renewing LetsEncrypt certificates via ${dnsPlugin.name} for Cert #${certificate.id}: ${certificate.domain_names.join(", ")}`,
  920. );
  921. const args = [
  922. "renew",
  923. "--force-renewal",
  924. "--config",
  925. letsencryptConfig,
  926. "--work-dir",
  927. certbotWorkDir,
  928. "--logs-dir",
  929. certbotLogsDir,
  930. "--cert-name",
  931. `npm-${certificate.id}`,
  932. "--preferred-challenges",
  933. "dns",
  934. "--disable-hook-validation",
  935. "--no-random-sleep-on-renew",
  936. ];
  937. const adds = internalCertificate.getAdditionalCertbotArgs(
  938. certificate.id,
  939. certificate.meta.dns_provider,
  940. );
  941. args.push(...adds.args);
  942. logger.info(`Command: ${certbotCommand} ${args ? args.join(" ") : ""}`);
  943. const result = await utils.execFile(certbotCommand, args, adds.opts);
  944. logger.info(result);
  945. return result;
  946. },
  947. /**
  948. * @param {Object} certificate the certificate row
  949. * @param {Boolean} [throwErrors]
  950. * @returns {Promise}
  951. */
  952. revokeLetsEncryptSsl: async (certificate, throwErrors) => {
  953. logger.info(
  954. `Revoking LetsEncrypt certificates for Cert #${certificate.id}: ${certificate.domain_names.join(", ")}`,
  955. );
  956. const args = [
  957. "revoke",
  958. "--config",
  959. letsencryptConfig,
  960. "--work-dir",
  961. certbotWorkDir,
  962. "--logs-dir",
  963. certbotLogsDir,
  964. "--cert-path",
  965. `${internalCertificate.getLiveCertPath(certificate.id)}/fullchain.pem`,
  966. "--delete-after-revoke",
  967. ];
  968. const adds = internalCertificate.getAdditionalCertbotArgs(certificate.id);
  969. args.push(...adds.args);
  970. logger.info(`Command: ${certbotCommand} ${args ? args.join(" ") : ""}`);
  971. try {
  972. const result = await utils.execFile(certbotCommand, args, adds.opts);
  973. await utils.exec(
  974. `rm -f '/etc/letsencrypt/credentials/credentials-${certificate.id}' || true`,
  975. );
  976. logger.info(result);
  977. return result;
  978. } catch (err) {
  979. logger.error(err.message);
  980. if (throwErrors) {
  981. throw err;
  982. }
  983. }
  984. },
  985. /**
  986. * @param {Object} certificate
  987. * @returns {Boolean}
  988. */
  989. hasLetsEncryptSslCerts: (certificate) => {
  990. const letsencryptPath = internalCertificate.getLiveCertPath(certificate.id);
  991. return (
  992. fs.existsSync(`${letsencryptPath}/fullchain.pem`) &&
  993. fs.existsSync(`${letsencryptPath}/privkey.pem`)
  994. );
  995. },
  996. /**
  997. * @param {Object} inUseResult
  998. * @param {Number} inUseResult.total_count
  999. * @param {Array} inUseResult.proxy_hosts
  1000. * @param {Array} inUseResult.redirection_hosts
  1001. * @param {Array} inUseResult.dead_hosts
  1002. * @returns {Promise}
  1003. */
  1004. disableInUseHosts: async (inUseResult) => {
  1005. if (inUseResult?.total_count) {
  1006. if (inUseResult?.proxy_hosts.length) {
  1007. await internalNginx.bulkDeleteConfigs(
  1008. "proxy_host",
  1009. inUseResult.proxy_hosts,
  1010. );
  1011. }
  1012. if (inUseResult?.redirection_hosts.length) {
  1013. await internalNginx.bulkDeleteConfigs(
  1014. "redirection_host",
  1015. inUseResult.redirection_hosts,
  1016. );
  1017. }
  1018. if (inUseResult?.dead_hosts.length) {
  1019. await internalNginx.bulkDeleteConfigs(
  1020. "dead_host",
  1021. inUseResult.dead_hosts,
  1022. );
  1023. }
  1024. }
  1025. },
  1026. /**
  1027. * @param {Object} inUseResult
  1028. * @param {Number} inUseResult.total_count
  1029. * @param {Array} inUseResult.proxy_hosts
  1030. * @param {Array} inUseResult.redirection_hosts
  1031. * @param {Array} inUseResult.dead_hosts
  1032. * @returns {Promise}
  1033. */
  1034. enableInUseHosts: async (inUseResult) => {
  1035. if (inUseResult.total_count) {
  1036. if (inUseResult.proxy_hosts.length) {
  1037. await internalNginx.bulkGenerateConfigs(
  1038. "proxy_host",
  1039. inUseResult.proxy_hosts,
  1040. );
  1041. }
  1042. if (inUseResult.redirection_hosts.length) {
  1043. await internalNginx.bulkGenerateConfigs(
  1044. "redirection_host",
  1045. inUseResult.redirection_hosts,
  1046. );
  1047. }
  1048. if (inUseResult.dead_hosts.length) {
  1049. await internalNginx.bulkGenerateConfigs(
  1050. "dead_host",
  1051. inUseResult.dead_hosts,
  1052. );
  1053. }
  1054. }
  1055. },
  1056. /**
  1057. *
  1058. * @param {Object} payload
  1059. * @param {string[]} payload.domains
  1060. * @returns
  1061. */
  1062. testHttpsChallenge: async (access, payload) => {
  1063. await access.can("certificates:list");
  1064. // Create a test challenge file
  1065. const testChallengeDir =
  1066. "/data/letsencrypt-acme-challenge/.well-known/acme-challenge";
  1067. const testChallengeFile = `${testChallengeDir}/test-challenge`;
  1068. fs.mkdirSync(testChallengeDir, { recursive: true });
  1069. fs.writeFileSync(testChallengeFile, "Success", { encoding: "utf8" });
  1070. const results = {};
  1071. for (const domain of payload.domains) {
  1072. results[domain] = await internalCertificate.performTestForDomain(domain);
  1073. }
  1074. // Remove the test challenge file
  1075. fs.unlinkSync(testChallengeFile);
  1076. return results;
  1077. },
  1078. performTestForDomain: async (domain) => {
  1079. logger.info(`Testing http challenge for ${domain}`);
  1080. const url = `http://${domain}/.well-known/acme-challenge/test-challenge`;
  1081. const formBody = `method=G&url=${encodeURI(url)}&bodytype=T&requestbody=&headername=User-Agent&headervalue=None&locationid=1&ch=false&cc=false`;
  1082. const options = {
  1083. method: "POST",
  1084. headers: {
  1085. "User-Agent": "Mozilla/5.0",
  1086. "Content-Type": "application/x-www-form-urlencoded",
  1087. "Content-Length": Buffer.byteLength(formBody),
  1088. },
  1089. };
  1090. const result = await new Promise((resolve) => {
  1091. const req = https.request(
  1092. "https://www.site24x7.com/tools/restapi-tester",
  1093. options,
  1094. (res) => {
  1095. let responseBody = "";
  1096. res.on("data", (chunk) => {
  1097. responseBody = responseBody + chunk;
  1098. });
  1099. res.on("end", () => {
  1100. try {
  1101. const parsedBody = JSON.parse(`${responseBody}`);
  1102. if (res.statusCode !== 200) {
  1103. logger.warn(
  1104. `Failed to test HTTP challenge for domain ${domain} because HTTP status code ${res.statusCode} was returned: ${parsedBody.message}`,
  1105. );
  1106. resolve(undefined);
  1107. } else {
  1108. resolve(parsedBody);
  1109. }
  1110. } catch (err) {
  1111. if (res.statusCode !== 200) {
  1112. logger.warn(
  1113. `Failed to test HTTP challenge for domain ${domain} because HTTP status code ${res.statusCode} was returned`,
  1114. );
  1115. } else {
  1116. logger.warn(
  1117. `Failed to test HTTP challenge for domain ${domain} because response failed to be parsed: ${err.message}`,
  1118. );
  1119. }
  1120. resolve(undefined);
  1121. }
  1122. });
  1123. },
  1124. );
  1125. // Make sure to write the request body.
  1126. req.write(formBody);
  1127. req.end();
  1128. req.on("error", (e) => {
  1129. logger.warn(`Failed to test HTTP challenge for domain ${domain}`, e);
  1130. resolve(undefined);
  1131. });
  1132. });
  1133. if (!result) {
  1134. // Some error occurred while trying to get the data
  1135. return "failed";
  1136. }
  1137. if (result.error) {
  1138. logger.info(
  1139. `HTTP challenge test failed for domain ${domain} because error was returned: ${result.error.msg}`,
  1140. );
  1141. return `other:${result.error.msg}`;
  1142. }
  1143. if (
  1144. `${result.responsecode}` === "200" &&
  1145. result.htmlresponse === "Success"
  1146. ) {
  1147. // Server exists and has responded with the correct data
  1148. return "ok";
  1149. }
  1150. if (`${result.responsecode}` === "200") {
  1151. // Server exists but has responded with wrong data
  1152. logger.info(
  1153. `HTTP challenge test failed for domain ${domain} because of invalid returned data:`,
  1154. result.htmlresponse,
  1155. );
  1156. return "wrong-data";
  1157. }
  1158. if (`${result.responsecode}` === "404") {
  1159. // Server exists but responded with a 404
  1160. logger.info(
  1161. `HTTP challenge test failed for domain ${domain} because code 404 was returned`,
  1162. );
  1163. return "404";
  1164. }
  1165. if (
  1166. `${result.responsecode}` === "0" ||
  1167. (typeof result.reason === "string" &&
  1168. result.reason.toLowerCase() === "host unavailable")
  1169. ) {
  1170. // Server does not exist at domain
  1171. logger.info(
  1172. `HTTP challenge test failed for domain ${domain} the host was not found`,
  1173. );
  1174. return "no-host";
  1175. }
  1176. // Other errors
  1177. logger.info(
  1178. `HTTP challenge test failed for domain ${domain} because code ${result.responsecode} was returned`,
  1179. );
  1180. return `other:${result.responsecode}`;
  1181. },
  1182. getAdditionalCertbotArgs: (certificate_id, dns_provider) => {
  1183. const args = [];
  1184. if (useLetsencryptServer() !== null) {
  1185. args.push("--server", useLetsencryptServer());
  1186. }
  1187. if (useLetsencryptStaging() && useLetsencryptServer() === null) {
  1188. args.push("--staging");
  1189. }
  1190. // For route53, add the credentials file as an environment variable,
  1191. // inheriting the process env
  1192. const opts = {};
  1193. if (certificate_id && dns_provider === "route53") {
  1194. opts.env = process.env;
  1195. opts.env.AWS_CONFIG_FILE = `/etc/letsencrypt/credentials/credentials-${certificate_id}`;
  1196. }
  1197. if (dns_provider === "duckdns") {
  1198. args.push("--dns-duckdns-no-txt-restore");
  1199. }
  1200. return { args: args, opts: opts };
  1201. },
  1202. getLiveCertPath: (certificateId) => {
  1203. return `/etc/letsencrypt/live/npm-${certificateId}`;
  1204. },
  1205. };
  1206. export default internalCertificate;