certificate.js 36 KB

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