certificate.js 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570
  1. 'use strict';
  2. const fs = require('fs');
  3. const _ = require('lodash');
  4. const logger = require('../logger').ssl;
  5. const error = require('../lib/error');
  6. const certificateModel = require('../models/certificate');
  7. const internalAuditLog = require('./audit-log');
  8. const tempWrite = require('temp-write');
  9. const utils = require('../lib/utils');
  10. const moment = require('moment');
  11. const debug_mode = process.env.NODE_ENV !== 'production';
  12. const certbot_command = '/usr/bin/certbot';
  13. function omissions () {
  14. return ['is_deleted'];
  15. }
  16. const internalCertificate = {
  17. allowed_ssl_files: ['certificate', 'certificate_key', 'intermediate_certificate'],
  18. interval_timeout: 1000 * 60 * 60 * 12, // 12 hours
  19. interval: null,
  20. interval_processing: false,
  21. initTimer: () => {
  22. logger.info('Let\'s Encrypt Renewal Timer initialized');
  23. internalCertificate.interval = setInterval(internalCertificate.processExpiringHosts, internalCertificate.interval_timeout);
  24. },
  25. /**
  26. * Triggered by a timer, this will check for expiring hosts and renew their ssl certs if required
  27. */
  28. processExpiringHosts: () => {
  29. let internalNginx = require('./nginx');
  30. if (!internalCertificate.interval_processing) {
  31. internalCertificate.interval_processing = true;
  32. logger.info('Renewing SSL certs close to expiry...');
  33. return utils.exec(certbot_command + ' renew -q ' + (debug_mode ? '--staging' : ''))
  34. .then(result => {
  35. logger.info(result);
  36. internalCertificate.interval_processing = false;
  37. return internalNginx.reload()
  38. .then(() => {
  39. logger.info('Renew Complete');
  40. return result;
  41. });
  42. })
  43. .catch(err => {
  44. logger.error(err);
  45. internalCertificate.interval_processing = false;
  46. });
  47. }
  48. },
  49. /**
  50. * @param {Access} access
  51. * @param {Object} data
  52. * @returns {Promise}
  53. */
  54. create: (access, data) => {
  55. return access.can('certificates:create', data)
  56. .then(() => {
  57. data.owner_user_id = access.token.get('attrs').id;
  58. if (data.provider === 'letsencrypt') {
  59. data.nice_name = data.domain_names.sort().join(', ');
  60. }
  61. return certificateModel
  62. .query()
  63. .omit(omissions())
  64. .insertAndFetch(data);
  65. })
  66. .then(row => {
  67. data.meta = _.assign({}, data.meta || {}, row.meta);
  68. // Add to audit log
  69. return internalAuditLog.add(access, {
  70. action: 'created',
  71. object_type: 'certificate',
  72. object_id: row.id,
  73. meta: data
  74. })
  75. .then(() => {
  76. return row;
  77. });
  78. });
  79. },
  80. /**
  81. * @param {Access} access
  82. * @param {Object} data
  83. * @param {Integer} data.id
  84. * @param {String} [data.email]
  85. * @param {String} [data.name]
  86. * @return {Promise}
  87. */
  88. update: (access, data) => {
  89. return access.can('certificates:update', data.id)
  90. .then(access_data => {
  91. return internalCertificate.get(access, {id: data.id});
  92. })
  93. .then(row => {
  94. if (row.id !== data.id) {
  95. // Sanity check that something crazy hasn't happened
  96. throw new error.InternalValidationError('Certificate could not be updated, IDs do not match: ' + row.id + ' !== ' + data.id);
  97. }
  98. return certificateModel
  99. .query()
  100. .omit(omissions())
  101. .patchAndFetchById(row.id, data)
  102. .debug()
  103. .then(saved_row => {
  104. saved_row.meta = internalCertificate.cleanMeta(saved_row.meta);
  105. data.meta = internalCertificate.cleanMeta(data.meta);
  106. // Add row.nice_name for custom certs
  107. if (saved_row.provider === 'other') {
  108. data.nice_name = saved_row.nice_name;
  109. }
  110. // Add to audit log
  111. return internalAuditLog.add(access, {
  112. action: 'updated',
  113. object_type: 'certificate',
  114. object_id: row.id,
  115. meta: _.omit(data, ['expires_on']) // this prevents json circular reference because expires_on might be raw
  116. })
  117. .then(() => {
  118. return _.omit(saved_row, omissions());
  119. });
  120. });
  121. });
  122. },
  123. /**
  124. * @param {Access} access
  125. * @param {Object} data
  126. * @param {Integer} data.id
  127. * @param {Array} [data.expand]
  128. * @param {Array} [data.omit]
  129. * @return {Promise}
  130. */
  131. get: (access, data) => {
  132. if (typeof data === 'undefined') {
  133. data = {};
  134. }
  135. if (typeof data.id === 'undefined' || !data.id) {
  136. data.id = access.token.get('attrs').id;
  137. }
  138. return access.can('certificates:get', data.id)
  139. .then(access_data => {
  140. let query = certificateModel
  141. .query()
  142. .where('is_deleted', 0)
  143. .andWhere('id', data.id)
  144. .allowEager('[owner]')
  145. .first();
  146. if (access_data.permission_visibility !== 'all') {
  147. query.andWhere('owner_user_id', access.token.get('attrs').id);
  148. }
  149. // Custom omissions
  150. if (typeof data.omit !== 'undefined' && data.omit !== null) {
  151. query.omit(data.omit);
  152. }
  153. if (typeof data.expand !== 'undefined' && data.expand !== null) {
  154. query.eager('[' + data.expand.join(', ') + ']');
  155. }
  156. return query;
  157. })
  158. .then(row => {
  159. if (row) {
  160. return _.omit(row, omissions());
  161. } else {
  162. throw new error.ItemNotFoundError(data.id);
  163. }
  164. });
  165. },
  166. /**
  167. * @param {Access} access
  168. * @param {Object} data
  169. * @param {Integer} data.id
  170. * @param {String} [data.reason]
  171. * @returns {Promise}
  172. */
  173. delete: (access, data) => {
  174. return access.can('certificates:delete', data.id)
  175. .then(() => {
  176. return internalCertificate.get(access, {id: data.id});
  177. })
  178. .then(row => {
  179. if (!row) {
  180. throw new error.ItemNotFoundError(data.id);
  181. }
  182. return certificateModel
  183. .query()
  184. .where('id', row.id)
  185. .patch({
  186. is_deleted: 1
  187. })
  188. .then(() => {
  189. // Add to audit log
  190. row.meta = internalCertificate.cleanMeta(row.meta);
  191. return internalAuditLog.add(access, {
  192. action: 'deleted',
  193. object_type: 'certificate',
  194. object_id: row.id,
  195. meta: _.omit(row, omissions())
  196. });
  197. });
  198. })
  199. .then(() => {
  200. return true;
  201. });
  202. },
  203. /**
  204. * All Lists
  205. *
  206. * @param {Access} access
  207. * @param {Array} [expand]
  208. * @param {String} [search_query]
  209. * @returns {Promise}
  210. */
  211. getAll: (access, expand, search_query) => {
  212. return access.can('certificates:list')
  213. .then(access_data => {
  214. let query = certificateModel
  215. .query()
  216. .where('is_deleted', 0)
  217. .groupBy('id')
  218. .omit(['is_deleted'])
  219. .allowEager('[owner]')
  220. .orderBy('nice_name', 'ASC');
  221. if (access_data.permission_visibility !== 'all') {
  222. query.andWhere('owner_user_id', access.token.get('attrs').id);
  223. }
  224. // Query is used for searching
  225. if (typeof search_query === 'string') {
  226. query.where(function () {
  227. this.where('name', 'like', '%' + search_query + '%');
  228. });
  229. }
  230. if (typeof expand !== 'undefined' && expand !== null) {
  231. query.eager('[' + expand.join(', ') + ']');
  232. }
  233. return query;
  234. });
  235. },
  236. /**
  237. * Report use
  238. *
  239. * @param {Integer} user_id
  240. * @param {String} visibility
  241. * @returns {Promise}
  242. */
  243. getCount: (user_id, visibility) => {
  244. let query = certificateModel
  245. .query()
  246. .count('id as count')
  247. .where('is_deleted', 0);
  248. if (visibility !== 'all') {
  249. query.andWhere('owner_user_id', user_id);
  250. }
  251. return query.first()
  252. .then(row => {
  253. return parseInt(row.count, 10);
  254. });
  255. },
  256. /**
  257. * @param {Access} access
  258. * @param {Object} data
  259. * @param {Array} data.domain_names
  260. * @param {String} data.meta.letsencrypt_email
  261. * @param {Boolean} data.meta.letsencrypt_agree
  262. * @returns {Promise}
  263. */
  264. createQuickCertificate: (access, data) => {
  265. return internalCertificate.create(access, {
  266. provider: 'letsencrypt',
  267. domain_names: data.domain_names,
  268. meta: data.meta
  269. });
  270. },
  271. /**
  272. * Validates that the certs provided are good.
  273. * No access required here, nothing is changed or stored.
  274. *
  275. * @param {Object} data
  276. * @param {Object} data.files
  277. * @returns {Promise}
  278. */
  279. validate: data => {
  280. return new Promise(resolve => {
  281. // Put file contents into an object
  282. let files = {};
  283. _.map(data.files, (file, name) => {
  284. if (internalCertificate.allowed_ssl_files.indexOf(name) !== -1) {
  285. files[name] = file.data.toString();
  286. }
  287. });
  288. resolve(files);
  289. })
  290. .then(files => {
  291. // For each file, create a temp file and write the contents to it
  292. // Then test it depending on the file type
  293. let promises = [];
  294. _.map(files, (content, type) => {
  295. promises.push(new Promise((resolve, reject) => {
  296. if (type === 'certificate_key') {
  297. resolve(internalCertificate.checkPrivateKey(content));
  298. } else {
  299. // this should handle `certificate` and intermediate certificate
  300. resolve(internalCertificate.getCertificateInfo(content, true));
  301. }
  302. }).then(res => {
  303. return {[type]: res};
  304. }));
  305. });
  306. return Promise.all(promises)
  307. .then(files => {
  308. let data = {};
  309. _.each(files, file => {
  310. data = _.assign({}, data, file);
  311. });
  312. return data;
  313. });
  314. });
  315. },
  316. /**
  317. * @param {Access} access
  318. * @param {Object} data
  319. * @param {Integer} data.id
  320. * @param {Object} data.files
  321. * @returns {Promise}
  322. */
  323. upload: (access, data) => {
  324. return internalCertificate.get(access, {id: data.id})
  325. .then(row => {
  326. if (row.provider !== 'other') {
  327. throw new error.ValidationError('Cannot upload certificates for this type of provider');
  328. }
  329. return internalCertificate.validate(data)
  330. .then(validations => {
  331. if (typeof validations.certificate === 'undefined') {
  332. throw new error.ValidationError('Certificate file was not provided');
  333. }
  334. _.map(data.files, (file, name) => {
  335. if (internalCertificate.allowed_ssl_files.indexOf(name) !== -1) {
  336. row.meta[name] = file.data.toString();
  337. }
  338. });
  339. return internalCertificate.update(access, {
  340. id: data.id,
  341. expires_on: certificateModel.raw('FROM_UNIXTIME(' + validations.certificate.dates.to + ')'),
  342. domain_names: [validations.certificate.cn],
  343. meta: row.meta
  344. });
  345. })
  346. .then(() => {
  347. return _.pick(row.meta, internalCertificate.allowed_ssl_files);
  348. });
  349. });
  350. },
  351. /**
  352. * Uses the openssl command to validate the private key.
  353. * It will save the file to disk first, then run commands on it, then delete the file.
  354. *
  355. * @param {String} private_key This is the entire key contents as a string
  356. */
  357. checkPrivateKey: private_key => {
  358. return tempWrite(private_key, '/tmp')
  359. .then(filepath => {
  360. return utils.exec('openssl rsa -in ' + filepath + ' -check -noout')
  361. .then(result => {
  362. if (!result.toLowerCase().includes('key ok')) {
  363. throw new error.ValidationError(result);
  364. }
  365. fs.unlinkSync(filepath);
  366. return true;
  367. }).catch(err => {
  368. fs.unlinkSync(filepath);
  369. throw new error.ValidationError('Certificate Key is not valid (' + err.message + ')', err);
  370. });
  371. });
  372. },
  373. /**
  374. * Uses the openssl command to both validate and get info out of the certificate.
  375. * It will save the file to disk first, then run commands on it, then delete the file.
  376. *
  377. * @param {String} certificate This is the entire cert contents as a string
  378. * @param {Boolean} [throw_expired] Throw when the certificate is out of date
  379. */
  380. getCertificateInfo: (certificate, throw_expired) => {
  381. return tempWrite(certificate, '/tmp')
  382. .then(filepath => {
  383. let cert_data = {};
  384. return utils.exec('openssl x509 -in ' + filepath + ' -subject -noout')
  385. .then(result => {
  386. // subject=CN = something.example.com
  387. let regex = /(?:subject=)?[^=]+=\s+(\S+)/gim;
  388. let match = regex.exec(result);
  389. if (typeof match[1] === 'undefined') {
  390. throw new error.ValidationError('Could not determine subject from certificate: ' + result);
  391. }
  392. cert_data['cn'] = match[1];
  393. })
  394. .then(() => {
  395. return utils.exec('openssl x509 -in ' + filepath + ' -issuer -noout');
  396. })
  397. .then(result => {
  398. // issuer=C = US, O = Let's Encrypt, CN = Let's Encrypt Authority X3
  399. let regex = /^(?:issuer=)?(.*)$/gim;
  400. let match = regex.exec(result);
  401. if (typeof match[1] === 'undefined') {
  402. throw new error.ValidationError('Could not determine issuer from certificate: ' + result);
  403. }
  404. cert_data['issuer'] = match[1];
  405. })
  406. .then(() => {
  407. return utils.exec('openssl x509 -in ' + filepath + ' -dates -noout');
  408. })
  409. .then(result => {
  410. // notBefore=Jul 14 04:04:29 2018 GMT
  411. // notAfter=Oct 12 04:04:29 2018 GMT
  412. let valid_from = null;
  413. let valid_to = null;
  414. let lines = result.split('\n');
  415. lines.map(function (str) {
  416. let regex = /^(\S+)=(.*)$/gim;
  417. let match = regex.exec(str.trim());
  418. if (match && typeof match[2] !== 'undefined') {
  419. let date = parseInt(moment(match[2], 'MMM DD HH:mm:ss YYYY z').format('X'), 10);
  420. if (match[1].toLowerCase() === 'notbefore') {
  421. valid_from = date;
  422. } else if (match[1].toLowerCase() === 'notafter') {
  423. valid_to = date;
  424. }
  425. }
  426. });
  427. if (!valid_from || !valid_to) {
  428. throw new error.ValidationError('Could not determine dates from certificate: ' + result);
  429. }
  430. if (throw_expired && valid_to < parseInt(moment().format('X'), 10)) {
  431. throw new error.ValidationError('Certificate has expired');
  432. }
  433. cert_data['dates'] = {
  434. from: valid_from,
  435. to: valid_to
  436. };
  437. })
  438. .then(() => {
  439. fs.unlinkSync(filepath);
  440. return cert_data;
  441. }).catch(err => {
  442. fs.unlinkSync(filepath);
  443. throw new error.ValidationError('Certificate is not valid (' + err.message + ')', err);
  444. });
  445. });
  446. },
  447. /**
  448. * Cleans the ssl keys from the meta object and sets them to "true"
  449. *
  450. * @param {Object} meta
  451. * @param {Boolean} [remove]
  452. * @returns {Object}
  453. */
  454. cleanMeta: function (meta, remove) {
  455. internalCertificate.allowed_ssl_files.map(key => {
  456. if (typeof meta[key] !== 'undefined' && meta[key]) {
  457. if (remove) {
  458. delete meta[key];
  459. } else {
  460. meta[key] = true;
  461. }
  462. }
  463. });
  464. return meta;
  465. },
  466. /**
  467. * @param {Object} certificate the certificate row
  468. * @returns {Promise}
  469. */
  470. requestLetsEncryptSsl: certificate => {
  471. logger.info('Requesting Let\'sEncrypt certificates for Cert #' + certificate.id + ': ' + certificate.domain_names.join(', '));
  472. return utils.exec(certbot_command + ' certonly --cert-name "npm-' + certificate.id + '" --agree-tos ' +
  473. '--email "' + certificate.meta.letsencrypt_email + '" ' +
  474. '--preferred-challenges "http" ' +
  475. '-n -a webroot -d "' + certificate.domain_names.join(',') + '" ' +
  476. (debug_mode ? '--staging' : ''))
  477. .then(result => {
  478. logger.info(result);
  479. return result;
  480. });
  481. },
  482. /**
  483. * @param {Object} certificate the certificate row
  484. * @returns {Promise}
  485. */
  486. renewLetsEncryptSsl: certificate => {
  487. logger.info('Renewing Let\'sEncrypt certificates for Cert #' + certificate.id + ': ' + certificate.domain_names.join(', '));
  488. return utils.exec(certbot_command + ' renew -n --force-renewal --disable-hook-validation --cert-name "npm-' + certificate.id + '" ' + (debug_mode ? '--staging' : ''))
  489. .then(result => {
  490. logger.info(result);
  491. return result;
  492. });
  493. },
  494. /**
  495. * @param {Object} certificate
  496. * @returns {Boolean}
  497. */
  498. hasLetsEncryptSslCerts: certificate => {
  499. let le_path = '/etc/letsencrypt/live/npm-' + certificate.id;
  500. return fs.existsSync(le_path + '/fullchain.pem') && fs.existsSync(le_path + '/privkey.pem');
  501. }
  502. };
  503. module.exports = internalCertificate;