diff --git a/src/routes/ssh.js b/src/routes/ssh.js index 9300b33d3..f106d6186 100644 --- a/src/routes/ssh.js +++ b/src/routes/ssh.js @@ -10,7 +10,8 @@ exports = module.exports = { var assert = require('assert'), HttpError = require('connect-lastmile').HttpError, HttpSuccess = require('connect-lastmile').HttpSuccess, - ssh = require('../ssh.js'); + ssh = require('../ssh.js'), + SshError = ssh.SshError; function getAuthorizedKeys(req, res, next) { ssh.getAuthorizedKeys(function (error, result) { @@ -23,6 +24,7 @@ function getAuthorizedKey(req, res, next) { assert.strictEqual(typeof req.params.identifier, 'string'); ssh.getAuthorizedKey(req.params.identifier, function (error, result) { + if (error && error.reason === SshError.NOT_FOUND) return next(new HttpError(404, error.message)); if (error) return next(new HttpError(500, error)); next(new HttpSuccess(200, { identifier: result.identifier, key: result.key })); }); @@ -34,7 +36,9 @@ function addAuthorizedKey(req, res, next) { if (typeof req.body.key !== 'string' || !req.body.key) return next(new HttpError(400, 'key must be a non empty')); ssh.addAuthorizedKey(req.body.key, function (error) { + if (error && error.reason === SshError.INVALID_KEY) return next(new HttpError(400, error.message)); if (error) return next(new HttpError(500, error)); + next(new HttpSuccess(201, {})); }); } @@ -43,7 +47,8 @@ function delAuthorizedKey(req, res, next) { assert.strictEqual(typeof req.params.identifier, 'string'); ssh.delAuthorizedKey(req.params.identifier, function (error) { + if (error && error.reason === SshError.NOT_FOUND) return next(new HttpError(404, error.message)); if (error) return next(new HttpError(500, error)); - next(new HttpSuccess(201, {})); + next(new HttpSuccess(202, {})); }); } diff --git a/src/routes/test/ssh-test.js b/src/routes/test/ssh-test.js new file mode 100644 index 000000000..36f67e90e --- /dev/null +++ b/src/routes/test/ssh-test.js @@ -0,0 +1,247 @@ +/* global it:false */ +/* global describe:false */ +/* global before:false */ +/* global after:false */ + +'use strict'; + +var ssh = require('../../ssh.js'), + async = require('async'), + child_process = require('child_process'), + cloudron = require('../../cloudron.js'), + config = require('../../config.js'), + constants = require('../../constants.js'), + database = require('../../database.js'), + expect = require('expect.js'), + path = require('path'), + paths = require('../../paths.js'), + superagent = require('superagent'), + server = require('../../server.js'), + settings = require('../../settings.js'), + fs = require('fs'), + nock = require('nock'); + +var SERVER_URL = 'http://localhost:' + config.get('port'); + +var USERNAME = 'superadmin', PASSWORD = 'Foobar?1337', EMAIL ='silly@me.com'; + +var INVALID_KEY_TYPE = 'ssh-foobar AAAAB3NzaC1yc2EAAAADAQABAAABAQCibC8G04mZy3o3AVMxjUMQoEQj0HSsl6AMVZDQK9A0e8qVRWft4HaRZdw0dW3iFDsEdny7s1zSAc5Kp5y38kJdSyEHGKxvcR8TaghUa8jpmu0sEVOTn+X4UtoonkNuJ0Jnl2tjPYsq5BtJmAeYUa1bKH5CjomCYi5OSfXRtnuZV6SiYX0A1OnZPXFWa/iFwwOUJQvDLGbFkgtJxqhxpc7yvzwFK5B9MNs7LJxA8+kRibJ9LTN1OKWNxb0oSk/PE6PFo9M2Q/SL9uj2IRXRipGj2XcOtZlqcAK5i+aq3UjjAGekztK2srQPcBkWbnI3Oim2N8l2purCfe0AoCCQHK7N nebulon@nebulon'; +var INVALID_KEY_VALUE = 'ssh-rsa foobar nebulon@nebulon'; +var INVALID_KEY_IDENTIFIER = 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCibC8G04mZy3o3AVMxjUMQoEQj0HSsl6AMVZDQK9A0e8qVRWft4HaRZdw0dW3iFDsEdny7s1zSAc5Kp5y38kJdSyEHGKxvcR8TaghUa8jpmu0sEVOTn+X4UtoonkNuJ0Jnl2tjPYsq5BtJmAeYUa1bKH5CjomCYi5OSfXRtnuZV6SiYX0A1OnZPXFWa/iFwwOUJQvDLGbFkgtJxqhxpc7yvzwFK5B9MNs7LJxA8+kRibJ9LTN1OKWNxb0oSk/PE6PFo9M2Q/SL9uj2IRXRipGj2XcOtZlqcAK5i+aq3UjjAGekztK2srQPcBkWbnI3Oim2N8l2purCfe0AoCCQHK7N'; +var VALID_KEY = 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCibC8G04mZy3o3AVMxjUMQoEQj0HSsl6AMVZDQK9A0e8qVRWft4HaRZdw0dW3iFDsEdny7s1zSAc5Kp5y38kJdSyEHGKxvcR8TaghUa8jpmu0sEVOTn+X4UtoonkNuJ0Jnl2tjPYsq5BtJmAeYUa1bKH5CjomCYi5OSfXRtnuZV6SiYX0A1OnZPXFWa/iFwwOUJQvDLGbFkgtJxqhxpc7yvzwFK5B9MNs7LJxA8+kRibJ9LTN1OKWNxb0oSk/PE6PFo9M2Q/SL9uj2IRXRipGj2XcOtZlqcAK5i+aq3UjjAGekztK2srQPcBkWbnI3Oim2N8l2purCfe0AoCCQHK7N nebulon@nebulon'; +var VALID_KEY_1 = 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCibC8G04mZy3o3AVMxjUMQoEQj0HSsl6AMVZDQK9A0e8qVRWft4HaRZdw0dW3iFDsEdny7s1zSAc5Kp5y38kJdSyEHGKxvcR8TaghUa8jpmu0sEVOTn+X4UtoonkNuJ0Jnl2tjPYsq5BtJmAeYUa1bKH5CjomCYi5OSfXRtnuZV6SiYX0A1OnZPXFWa/iFwwOUJQvDLGbFkgtJxqhxpc7yvzwFK5B9MNs7LJxA8+kRibJ9LTN1OKWNxb0oSk/PE6PFo9M2Q/SL9uj2IRXRipGj2XcOtZlqcAK5i+aq3UjjAGekztK2srQPcBkWbnI3Oim2N8l2purCfe0AoCCQHK7N muchmore'; + +var token = null; + +var server; +function setup(done) { + config.set('fqdn', 'foobar.com'); + + async.series([ + server.start.bind(server), + + ssh._clear, + database._clear, + + function createAdmin(callback) { + var scope1 = nock(config.apiServerOrigin()).get('/api/v1/boxes/' + config.fqdn() + '/setup/verify?setupToken=somesetuptoken').reply(200, {}); + var scope2 = nock(config.apiServerOrigin()).post('/api/v1/boxes/' + config.fqdn() + '/setup/done?setupToken=somesetuptoken').reply(201, {}); + + superagent.post(SERVER_URL + '/api/v1/cloudron/activate') + .query({ setupToken: 'somesetuptoken' }) + .send({ username: USERNAME, password: PASSWORD, email: EMAIL }) + .end(function (error, result) { + expect(result).to.be.ok(); + expect(result.statusCode).to.eql(201); + expect(scope1.isDone()).to.be.ok(); + expect(scope2.isDone()).to.be.ok(); + + // stash token for further use + token = result.body.token; + + callback(); + }); + } + ], done); +} + +function cleanup(done) { + database._clear(function (error) { + expect(!error).to.be.ok(); + + server.stop(done); + }); +} + +describe('SSH API', function () { + this.timeout(10000); + + before(setup); + after(cleanup); + + describe('add authorized_keys', function () { + it('fails due to missing key', function (done) { + superagent.put(SERVER_URL + '/api/v1/cloudron/ssh/authorized_keys') + .query({ access_token: token }) + .end(function (err, res) { + expect(res.statusCode).to.equal(400); + done(); + }); + }); + + it('fails due to empty key', function (done) { + superagent.put(SERVER_URL + '/api/v1/cloudron/ssh/authorized_keys') + .query({ access_token: token }) + .send({ key: '' }) + .end(function (err, res) { + expect(res.statusCode).to.equal(400); + done(); + }); + }); + + it('fails due to invalid key', function (done) { + superagent.put(SERVER_URL + '/api/v1/cloudron/ssh/authorized_keys') + .query({ access_token: token }) + .send({ key: 'foobar' }) + .end(function (err, res) { + expect(res.statusCode).to.equal(400); + done(); + }); + }); + + it('fails due to invalid key type', function (done) { + superagent.put(SERVER_URL + '/api/v1/cloudron/ssh/authorized_keys') + .query({ access_token: token }) + .send({ key: INVALID_KEY_TYPE }) + .end(function (err, res) { + expect(res.statusCode).to.equal(400); + done(); + }); + }); + + it('fails due to invalid key value', function (done) { + superagent.put(SERVER_URL + '/api/v1/cloudron/ssh/authorized_keys') + .query({ access_token: token }) + .send({ key: INVALID_KEY_VALUE }) + .end(function (err, res) { + expect(res.statusCode).to.equal(400); + done(); + }); + }); + + it('fails due to invalid key identifier', function (done) { + superagent.put(SERVER_URL + '/api/v1/cloudron/ssh/authorized_keys') + .query({ access_token: token }) + .send({ key: INVALID_KEY_IDENTIFIER }) + .end(function (err, res) { + expect(res.statusCode).to.equal(400); + done(); + }); + }); + + it('succeeds', function (done) { + superagent.put(SERVER_URL + '/api/v1/cloudron/ssh/authorized_keys') + .query({ access_token: token }) + .send({ key: VALID_KEY }) + .end(function (err, res) { + expect(res.statusCode).to.equal(201); + done(); + }); + }); + }); + + describe('get authorized_keys', function () { + it('fails for non existing key', function (done) { + superagent.get(SERVER_URL + '/api/v1/cloudron/ssh/authorized_keys/foobar') + .query({ access_token: token }) + .end(function (err, res) { + expect(res.statusCode).to.equal(404); + done(); + }); + }); + + it('succeeds', function (done) { + superagent.get(SERVER_URL + '/api/v1/cloudron/ssh/authorized_keys/' + VALID_KEY.split(' ')[2]) + .query({ access_token: token }) + .end(function (err, res) { + expect(res.statusCode).to.equal(200); + expect(res.body).to.be.an('object'); + expect(res.body.identifier).to.be.a('string'); + expect(res.body.identifier).to.equal(VALID_KEY.split(' ')[2]); + expect(res.body.key).to.equal(VALID_KEY); + done(); + }); + }); + }); + + describe('list authorized_keys', function () { + it('succeeds', function (done) { + superagent.get(SERVER_URL + '/api/v1/cloudron/ssh/authorized_keys') + .query({ access_token: token }) + .end(function (err, res) { + expect(res.statusCode).to.equal(200); + expect(res.body).to.be.an('object'); + expect(res.body.keys).to.be.an('array'); + expect(res.body.keys.length).to.equal(1); + expect(res.body.keys[0]).to.be.an('object'); + expect(res.body.keys[0].identifier).to.be.a('string'); + expect(res.body.keys[0].identifier).to.equal(VALID_KEY.split(' ')[2]); + expect(res.body.keys[0].key).to.equal(VALID_KEY); + done(); + }); + }); + + it('succeeds with two keys', function (done) { + superagent.put(SERVER_URL + '/api/v1/cloudron/ssh/authorized_keys') + .query({ access_token: token }) + .send({ key: VALID_KEY_1 }) + .end(function (err, res) { + expect(res.statusCode).to.equal(201); + + superagent.get(SERVER_URL + '/api/v1/cloudron/ssh/authorized_keys') + .query({ access_token: token }) + .end(function (err, res) { + expect(res.statusCode).to.equal(200); + expect(res.body).to.be.an('object'); + expect(res.body.keys).to.be.an('array'); + expect(res.body.keys.length).to.equal(2); + expect(res.body.keys[0]).to.be.an('object'); + expect(res.body.keys[0].identifier).to.be.a('string'); + expect(res.body.keys[0].identifier).to.equal(VALID_KEY_1.split(' ')[2]); + expect(res.body.keys[0].key).to.equal(VALID_KEY_1); + expect(res.body.keys[1]).to.be.an('object'); + expect(res.body.keys[1].identifier).to.be.a('string'); + expect(res.body.keys[1].identifier).to.equal(VALID_KEY.split(' ')[2]); + expect(res.body.keys[1].key).to.equal(VALID_KEY); + done(); + }); + }); + }); + }); + + describe('delete authorized_keys', function () { + it('fails for non existing key', function (done) { + superagent.del(SERVER_URL + '/api/v1/cloudron/ssh/authorized_keys/foobar') + .query({ access_token: token }) + .end(function (err, res) { + expect(res.statusCode).to.equal(404); + done(); + }); + }); + + it('succeeds', function (done) { + superagent.del(SERVER_URL + '/api/v1/cloudron/ssh/authorized_keys/' + VALID_KEY.split(' ')[2]) + .query({ access_token: token }) + .end(function (err, res) { + expect(res.statusCode).to.equal(202); + + superagent.get(SERVER_URL + '/api/v1/cloudron/ssh/authorized_keys/' + VALID_KEY.split(' ')[2]) + .query({ access_token: token }) + .end(function (err, res) { + expect(res.statusCode).to.equal(404); + done(); + }); + }); + }); + }); +}); diff --git a/src/server.js b/src/server.js index c50f386ad..f50c7eafe 100644 --- a/src/server.js +++ b/src/server.js @@ -107,7 +107,8 @@ function initializeExpressSync() { router.get ('/api/v1/cloudron/graphs', cloudronScope, routes.graphs.getGraphs); router.get ('/api/v1/cloudron/ssh/authorized_keys', cloudronScope, routes.ssh.getAuthorizedKeys); router.put ('/api/v1/cloudron/ssh/authorized_keys', cloudronScope, routes.ssh.addAuthorizedKey); - router.del ('/api/v1/cloudron/ssh/authorized_keys', cloudronScope, routes.ssh.delAuthorizedKey); + router.get ('/api/v1/cloudron/ssh/authorized_keys/:identifier', cloudronScope, routes.ssh.getAuthorizedKey); + router.del ('/api/v1/cloudron/ssh/authorized_keys/:identifier', cloudronScope, routes.ssh.delAuthorizedKey); // feedback router.post('/api/v1/cloudron/feedback', usersScope, routes.cloudron.feedback); diff --git a/src/ssh.js b/src/ssh.js index 7bf9c01bb..ef547e8ce 100644 --- a/src/ssh.js +++ b/src/ssh.js @@ -6,16 +6,22 @@ exports = module.exports = { getAuthorizedKeys: getAuthorizedKeys, getAuthorizedKey: getAuthorizedKey, addAuthorizedKey: addAuthorizedKey, - delAuthorizedKey: delAuthorizedKey + delAuthorizedKey: delAuthorizedKey, + + _clear: clear }; -// var AUTHORIZED_KEYS_FILEPATH = '/root/.ssh/authorized_keys'; -var AUTHORIZED_KEYS_FILEPATH = '/home/nebulon/.ssh/authorized_keys'; - var assert = require('assert'), + config = require('./config.js'), + fs = require('fs'), + path = require('path'), safe = require('safetydance'), util = require('util'); +var AUTHORIZED_KEYS_FILEPATH = config.TEST ? path.join(config.baseDir(), 'authorized_keys') : '/root/.ssh/authorized_keys'; +var VALID_KEY_TYPES = ['ssh-rsa']; // TODO add all supported ones +var VALID_MIN_KEY_LENGTH = 370; // TODO verify this length requirement + function SshError(reason, errorOrMessage) { assert.strictEqual(typeof reason, 'string'); assert(errorOrMessage instanceof Error || typeof errorOrMessage === 'string' || typeof errorOrMessage === 'undefined'); @@ -39,14 +45,22 @@ SshError.NOT_FOUND = 'Not found'; SshError.INVALID_KEY = 'Invalid key'; SshError.INTERNAL_ERROR = 'Internal Error'; +function clear(callback) { + assert.strictEqual(typeof callback, 'function'); + fs.unlink(AUTHORIZED_KEYS_FILEPATH, function (error) { + if (error && error.code !== 'ENOENT') return callback(error); + callback(); + }); +} + function getKeys() { var content = safe.fs.readFileSync(AUTHORIZED_KEYS_FILEPATH, 'utf8'); if (!content) return []; - var keys = content.split('/n') + var keys = content.split('\n') .filter(function (k) { return !!k.trim(); }) - .map(function (k) { return { identifier: k.split(' ')[2] || null, value: k }; }) - .filter(function (k) { return k.identifier && k.value; }); + .map(function (k) { return { identifier: k.split(' ')[2], key: k }; }) + .filter(function (k) { return k.identifier && k.key; }); return keys; } @@ -54,7 +68,7 @@ function getKeys() { function getAuthorizedKeys(callback) { assert.strictEqual(typeof callback, 'function'); - return callback(null, getKeys()); + return callback(null, getKeys().sort(function (a, b) { return a.identifier.localeCompare(b.identifier); })); } function getAuthorizedKey(identifier, callback) { @@ -76,14 +90,17 @@ function addAuthorizedKey(key, callback) { var tmp = key.split(' '); if (tmp.length !== 3) return callback(new SshError(SshError.INVALID_KEY)); + if (!VALID_KEY_TYPES.some(function (t) { return tmp[0] === t; })) return callback(new SshError(SshError.INVALID_KEY, 'Invalid key type')); + if (tmp[1].length < VALID_MIN_KEY_LENGTH) return callback(new SshError(SshError.INVALID_KEY)); + var identifier = tmp[2]; var keys = getKeys(); var index = keys.findIndex(function (k) { return k.identifier === identifier; }); - if (index !== -1) keys[index] = { identifier: identifier, value: key }; - else keys.push({ identifier: identifier, value: key }); + if (index !== -1) keys[index] = { identifier: identifier, key: key }; + else keys.push({ identifier: identifier, key: key }); - if (!safe.fs.writeFileSync(AUTHORIZED_KEYS_FILEPATH, keys.map(function (k) { return k.value; }).join('\n'))) { + if (!safe.fs.writeFileSync(AUTHORIZED_KEYS_FILEPATH, keys.map(function (k) { return k.key; }).join('\n'))) { console.error(safe.error); return callback(new SshError(SshError.INTERNAL_ERROR, safe.error)); } @@ -102,7 +119,7 @@ function delAuthorizedKey(identifier, callback) { // now remove the key keys.splice(index, 1); - if (!safe.fs.writeFileSync(AUTHORIZED_KEYS_FILEPATH, keys.map(function (k) { return k.value; }).join('\n'))) { + if (!safe.fs.writeFileSync(AUTHORIZED_KEYS_FILEPATH, keys.map(function (k) { return k.key; }).join('\n'))) { console.error(safe.error); return callback(new SshError(SshError.INTERNAL_ERROR, safe.error)); }