Skip to content

Commit

Permalink
feat(ssl): add ssl migration
Browse files Browse the repository at this point in the history
closes #495
  • Loading branch information
acburdine committed Nov 16, 2017
1 parent c2860f6 commit 3c60d89
Show file tree
Hide file tree
Showing 5 changed files with 209 additions and 1 deletion.
11 changes: 11 additions & 0 deletions extensions/nginx/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
69 changes: 69 additions & 0 deletions extensions/nginx/migrations.js
Original file line number Diff line number Diff line change
@@ -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
};
119 changes: 119 additions & 0 deletions extensions/nginx/test/migrations-spec.js
Original file line number Diff line number Diff line change
@@ -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=\'[email protected]\'\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',
'[email protected]',
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'));
});
});
});
});
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 9 additions & 1 deletion yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -3131,6 +3131,14 @@ repeating@^2.0.0:
dependencies:
is-finite "^1.0.0"

[email protected]:
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"
Expand Down Expand Up @@ -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:
Expand Down

0 comments on commit 3c60d89

Please sign in to comment.