diff --git a/extensions/nginx/index.js b/extensions/nginx/index.js index 1ec0aa527..cb7989dac 100644 --- a/extensions/nginx/index.js +++ b/extensions/nginx/index.js @@ -12,6 +12,17 @@ const template = require('lodash/template'); const cli = require('../../lib'); class NginxExtension extends cli.Extension { + migrations() { + const migrations = require('./migrations'); + + return [{ + before: '1.2.0', + title: 'Migrating SSL certs', + skip: () => os.platform() !== 'linux' || !fs.existsSync(path.join(os.homedir(), '.acme.sh')), + task: migrations.migrateSSL.bind(this) + }]; + } + setup(cmd, argv) { // ghost setup --local, skip if (argv.local) { diff --git a/extensions/nginx/migrations.js b/extensions/nginx/migrations.js new file mode 100644 index 000000000..53bf6a4cd --- /dev/null +++ b/extensions/nginx/migrations.js @@ -0,0 +1,69 @@ +'use strict'; +const fs = require('fs-extra'); +const os = require('os'); +const url = require('url'); +const path = require('path'); + +const cli = require('../../lib'); + +function migrateSSL(ctx, migrateTask) { + const replace = require('replace-in-file'); + const acme = require('./acme'); + + const parsedUrl = url.parse(ctx.instance.config.get('url')); + const confFile = path.join(ctx.instance.dir, 'system', 'files', `${parsedUrl.hostname}-ssl.conf`); + const rootPath = path.resolve(ctx.instance.dir, 'system', 'nginx-root'); + + if (!fs.existsSync(confFile)) { + return migrateTask.skip('SSL config has not been set up for this domain'); + } + + const originalAcmePath = path.join(os.homedir(), '.acme.sh'); + + // 1. parse ~/.acme.sh/account.conf to get the email + const accountConf = fs.readFileSync(path.join(originalAcmePath, 'account.conf'), {encoding: 'utf8'}); + const parsed = accountConf.match(/ACCOUNT_EMAIL='(.*)'\n/); + + if (!parsed) { + throw new cli.errors.SystemError('Unable to parse letsencrypt account email'); + } + + return this.ui.listr([{ + // 2. install acme.sh in /etc/letsencrypt if that hasn't been done already + title: 'Installing acme.sh in new location', + task: (ctx, task) => acme.install(this.ui, task) + }, { + // 3. run install cert for new acme.sh instance + title: 'Regenerating SSL certificate in new location', + task: () => acme.generate(this.ui, parsedUrl.hostname, rootPath, parsed[1], false) + }, { + // 4. Update cert locations in nginx-ssl.conf + title: 'Updating nginx config', + task: () => { + const acmeFolder = path.join('/etc/letsencrypt', parsedUrl.hostname); + + return replace({ + files: confFile, + from: [ + /ssl_certificate .*/, + /ssl_certificate_key .*/ + ], + to: [ + `ssl_certificate ${path.join(acmeFolder, 'fullchain.cer')};`, + `ssl_certificate_key ${path.join(acmeFolder, `${parsedUrl.hostname}.key`)};` + ] + }); + } + }, { + title: 'Restarting Nginx', + task: () => this.restartNginx() + }, { + // 5. run acme.sh --remove -d domain in old acme.sh directory to remove the old cert from renewal + title: 'Disabling renewal for old certificate', + task: () => acme.remove(parsedUrl.hostname, this.ui, originalAcmePath) + }], false); +} + +module.exports = { + migrateSSL: migrateSSL +}; diff --git a/extensions/nginx/test/migrations-spec.js b/extensions/nginx/test/migrations-spec.js new file mode 100644 index 000000000..d20511201 --- /dev/null +++ b/extensions/nginx/test/migrations-spec.js @@ -0,0 +1,119 @@ +'use strict'; + +const expect = require('chai').expect; +const sinon = require('sinon'); +const proxyquire = require('proxyquire').noCallThru(); + +const modulePath = '../migrations'; + +const cli = require('../../../lib'); + +const context = { + instance: { + dir: '/var/www/ghost', + config: { + get: () => 'https://ghost.org' + } + } +}; + +describe('Unit: Extensions > Nginx > Migrations', function () { + describe('migrateSSL', function () { + it('skips if ssl is not set up', function () { + const existsStub = sinon.stub().returns(false); + const skipStub = sinon.stub(); + + const migrate = proxyquire(modulePath, { + 'fs-extra': {existsSync: existsStub} + }); + + migrate.migrateSSL(context, {skip: skipStub}); + + expect(existsStub.calledOnce).to.be.true; + expect(existsStub.calledWithExactly('/var/www/ghost/system/files/ghost.org-ssl.conf')).to.be.true; + expect(skipStub.calledOnce).to.be.true; + }); + + it('throws an error if it can\'t parse the letsencrypt account email', function () { + const existsStub = sinon.stub().returns(true); + const rfsStub = sinon.stub().returns(''); + + const migrate = proxyquire(modulePath, { + 'fs-extra': {existsSync: existsStub, readFileSync: rfsStub}, + os: {homedir: () => '/home/ghost'} + }); + + try { + migrate.migrateSSL(context); + expect(false, 'error should have been thrown').to.be.true; + } catch (e) { + expect(e).to.be.an.instanceof(cli.errors.SystemError); + expect(e.message).to.equal('Unable to parse letsencrypt account email'); + + expect(rfsStub.calledWithExactly('/home/ghost/.acme.sh/account.conf')); + } + }); + + it('runs tasks correctly', function () { + const existsStub = sinon.stub().returns(true); + const rfsStub = sinon.stub().returns('ACCOUNT_EMAIL=\'test@example.com\'\n'); + const restartStub = sinon.stub().resolves(); + const replaceStub = sinon.stub().resolves(); + + const acme = { + install: sinon.stub().resolves(), + generate: sinon.stub().resolves(), + remove: sinon.stub().resolves() + }; + const ui = { + listr: sinon.stub() + }; + + const migrate = proxyquire(modulePath, { + 'fs-extra': {existsSync: existsStub, readFileSync: rfsStub}, + 'replace-in-file': replaceStub, + './acme': acme, + os: {homedir: () => '/home/ghost'} + }); + + const fn = migrate.migrateSSL.bind({ui: ui, restartNginx: restartStub}); + + fn(context); + + expect(existsStub.calledOnce).to.be.true; + expect(rfsStub.calledOnce).to.be.true; + expect(ui.listr.calledOnce).to.be.true; + + const tasks = ui.listr.getCall(0).args[0]; + expect(tasks).to.have.length(5); + + return tasks[0].task(null).then(() => { + expect(acme.install.calledOnce).to.be.true; + + return tasks[1].task(); + }).then(() => { + expect(acme.generate.calledOnce).to.be.true; + expect(acme.generate.calledWithExactly( + ui, + 'ghost.org', + '/var/www/ghost/system/nginx-root', + 'test@example.com', + false + )).to.be.true; + + return tasks[2].task(); + }).then(() => { + expect(replaceStub.calledOnce).to.be.true; + + return tasks[3].task(); + }).then(() => { + expect(restartStub.calledOnce).to.be.true; + + return tasks[4].task(); + }).then(() => { + expect(acme.remove.calledOnce).to.be.true; + expect(acme.remove.calledWithExactly('ghost.org', ui, '/home/ghost/.acme.sh')); + }); + }); + }); +}); diff --git a/package.json b/package.json index c0142faff..94c0b6da5 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "path-is-root": "0.1.0", "portfinder": "1.0.13", "read-last-lines": "1.2.0", + "replace-in-file": "2.6.4", "rxjs": "5.5.2", "semver": "5.4.1", "shasum": "1.0.2", diff --git a/yarn.lock b/yarn.lock index 0df0ac5d5..5ce4d0d71 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3131,6 +3131,14 @@ repeating@^2.0.0: dependencies: is-finite "^1.0.0" +replace-in-file@2.6.4: + version "2.6.4" + resolved "https://registry.yarnpkg.com/replace-in-file/-/replace-in-file-2.6.4.tgz#a80e25c5c0e0efe9d04afe01a4a57ff98e8b6461" + dependencies: + chalk "^2.1.0" + glob "^7.1.2" + yargs "^8.0.2" + request@^2.79.0, request@^2.83.0: version "2.83.0" resolved "https://registry.yarnpkg.com/request/-/request-2.83.0.tgz#ca0b65da02ed62935887808e6f510381034e3356" @@ -3962,7 +3970,7 @@ yargs@^3.19.0: window-size "^0.1.4" y18n "^3.2.0" -yargs@^8.0.1: +yargs@^8.0.1, yargs@^8.0.2: version "8.0.2" resolved "https://registry.yarnpkg.com/yargs/-/yargs-8.0.2.tgz#6299a9055b1cefc969ff7e79c1d918dceb22c360" dependencies: