diff --git a/.travis.yml b/.travis.yml index aebec01..1c3dbc2 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,13 +1,26 @@ -sudo: false +sudo: required language: node_js node_js: - - "4" - "6" +env: + global: + - PGPORT=5433 + - PGHOST=localhost + - DATABASE_URL=postgres://localhost:5433/spacekit_test + - CXX=g++-4.8 +services: + - postgresql addons: apt: sources: - ubuntu-toolchain-r-test + - precise-pgdg-9.5 packages: - g++-4.8 -env: - - CXX=g++-4.8 + - postgresql-9.5 + - postgresql-contrib-9.5 + postgresql: 9.5 +before_script: + - sudo cp /etc/postgresql/9.4/main/pg_hba.conf /etc/postgresql/9.5/main/pg_hba.conf + - sudo /etc/init.d/postgresql restart + - psql -c 'CREATE DATABASE spacekit_test;' -U postgres diff --git a/README.md b/README.md index 21721f9..26568d9 100644 --- a/README.md +++ b/README.md @@ -14,22 +14,52 @@ $ npm install spacekit-service -g ## Usage -If there is a `spacekit-service.json` file in the directory you run -`spacekit-service` from, we'll use it to configure the client. Typical -options are as follows: +You'll need a `spacekit-service.json` file in the directory you run the +service. Here's an example config: ```json { - "pg": "postgres://username:password@host:port/dbname", - "smtpUser": null, - "smtpPass": null, - "awsHostedZoneId": null, - "awsAccessKeyId": null, - "awsSecretAccessKey": null, - "host": "spacekit.io" + "postgres": "postgres://username:password@host:port/dbname", + "service": { + "domain": "spacekit.io", + "subdomains": { + "api": "api.spacekit.io", + "web": "www.spacekit.io" + }, + "ports": { + "http": 80, + "https": 443, + "range": { + "start": 8000, + "end": 8999 + } + } + }, + "letsEncrypt": { + "email": "spacekit.io@gmail.com" + }, + "aws": { + "hostedZoneId": null, + "accessKeyId": null, + "secretAccessKey": null, + "recordSetTtl": 1 + }, + "mail": { + "from": { + "name": "SpaceKit", + "address": "spacekit.io@gmail.com" + } + }, + "smtp": { + "host": "smtp.gmail.com", + "secure": true, + "user": "spacekit.io@gmail.com", + "pass": null + } } ``` + ## Logs Log files will be stored in the directory you run `spacekit-service` from. @@ -37,6 +67,12 @@ They're named `spacekit-service.log` and will rotate for 3 days (`spacekit-service.log.0`, `spacekit-service.log.1`, etc..). +## Certificates + +Certificate files will be stored in the directory you run `spacekit-service` +from under the `./certs` folder. + + ## License -Apache License, Version 2.0 +MIT diff --git a/bin/api-server.js b/bin/api-server.js new file mode 100755 index 0000000..d4b7b12 --- /dev/null +++ b/bin/api-server.js @@ -0,0 +1,13 @@ +#!/usr/bin/env node +'use strict'; +const ApiServer = require('../lib/api'); +const Http = require('http'); + +const server = Http.createServer(); +const port = process.env.PORT || 3000; + +ApiServer(server); + +server.listen(port, () => { + console.log(`listening on port ${port}`); +}); diff --git a/bin/web-server.js b/bin/web-server.js new file mode 100755 index 0000000..ee90a3b --- /dev/null +++ b/bin/web-server.js @@ -0,0 +1,13 @@ +#!/usr/bin/env node +'use strict'; +const WebServer = require('../lib/web'); +const Http = require('http'); + +const server = Http.createServer(); +const port = process.env.PORT || 3000; + +WebServer(server); + +server.listen(port, () => { + console.log(`listening on port ${port}`); +}); diff --git a/lib/api/auth.js b/lib/api/auth.js new file mode 100644 index 0000000..f4f686b --- /dev/null +++ b/lib/api/auth.js @@ -0,0 +1,169 @@ +'use strict'; +const Async = require('async'); +const Boom = require('boom'); +const Joi = require('joi'); +const Mailer = require('../mailer'); +const Secret = require('../secret'); +const Session = require('../db/session'); +const User = require('../db/user'); + +exports.register = function (server, options, next) { + server.route({ + method: 'POST', + path: '/auth/challenge', + config: { + validate: { + payload: { + username: Joi.string().token().lowercase().required(), + email: Joi.string().email().lowercase().required() + } + }, + pre: [{ + assign: 'userLookup', + method: function (request, reply) { + const username = request.payload.username; + const email = request.payload.email; + + User.findOneByCredentials(username, email, (err, user) => { + if (err) { + return reply(Boom.badImplementation('exception', err)); + } + + if (!user) { + return reply(Boom.conflict('User not found.')); + } + + reply(user); + }); + } + }] + }, + handler: function (request, reply) { + Async.auto({ + pin: function (done) { + Secret.createPin(done); + }, + challenge: ['pin', function (results, done) { + const userId = request.pre.userLookup.id; + const challenge = results.pin.hash; + const expires = new Date(Date.now() + 10000000); + + User.setChallenge(userId, challenge, expires, done); + }], + sendMail: ['challenge', function (results, done) { + const options = { + subject: 'SpaceKit auth challenge', + to: request.pre.userLookup.email + }; + const template = 'auth-challenge'; + const context = { + pin: results.pin.plain + }; + + Mailer.sendEmail(options, template, context, done); + }] + }, (err, results) => { + if (err) { + return reply(Boom.badImplementation('exception', err)); + } + + const message = 'An email will be sent with a challenge code.'; + + reply({ message: message }); + }); + } + }); + + server.route({ + method: 'POST', + path: '/auth/answer', + config: { + validate: { + payload: { + username: Joi.string().token().lowercase().required(), + email: Joi.string().email().lowercase().required(), + pin: Joi.number().integer().min(100000).max(999999).required() + } + }, + pre: [{ + assign: 'userLookup', + method: function (request, reply) { + const username = request.payload.username; + const email = request.payload.email; + + User.findOneByCredentials(username, email, (err, user) => { + if (err) { + return reply(Boom.badImplementation('exception', err)); + } + + if (!user) { + return reply(Boom.conflict('User not found.')); + } + + if (user.challenge === null) { + return reply(Boom.conflict('Challenge not set.')); + } + + const expiration = new Date(user.challenge_expires); + + if (expiration < new Date()) { + return reply(Boom.conflict('Challenge expired.')); + } + + reply(user); + }); + } + }, { + assign: 'comparePin', + method: function (request, reply) { + const pin = request.payload.pin; + const pinHash = request.pre.userLookup.challenge; + + Secret.compare(pin, pinHash, (err, pass) => { + if (err) { + return reply(Boom.badImplementation('exception', err)); + } + + if (!pass) { + return reply(Boom.conflict('Incorrect pin.')); + } + + reply(true); + }); + } + }] + }, + handler: function (request, reply) { + Async.auto({ + disableChallenge: function (done) { + const userId = request.pre.userLookup.id; + const challenge = null; + const expires = new Date(); + + User.setChallenge(userId, challenge, expires, done); + }, + authKey: function (done) { + Secret.createUuid(done); + }, + session: ['authKey', function (results, done) { + const userId = request.pre.userLookup.id; + const authKey = results.authKey.hash; + + Session.create(userId, authKey, done); + }] + }, (err, results) => { + if (err) { + return reply(Boom.badImplementation('exception', err)); + } + + reply({ authKey: results.authKey.plain }); + }); + } + }); + + next(); +}; + +exports.register.attributes = { + name: 'auth' +}; diff --git a/lib/api/hello.js b/lib/api/hello.js new file mode 100644 index 0000000..b86e177 --- /dev/null +++ b/lib/api/hello.js @@ -0,0 +1,17 @@ +'use strict'; + +exports.register = function (server, options, next) { + server.route({ + method: 'GET', + path: '/', + handler: function (request, reply) { + reply({ message: 'Welcome to the SpaceKit api.' }); + } + }); + + next(); +}; + +exports.register.attributes = { + name: 'hello' +}; diff --git a/lib/api/index.js b/lib/api/index.js index 475cf84..d1ca33e 100644 --- a/lib/api/index.js +++ b/lib/api/index.js @@ -1,45 +1,25 @@ 'use strict'; -const BodyParser = require('body-parser'); -const Cors = require('cors'); -const Express = require('express'); -const Uuid = require('node-uuid'); +const Auth = require('./auth'); +const Hapi = require('hapi'); +const Hello = require('./hello'); +const Signup = require('./signup'); -const CreateLogger = require('../create-logger'); -const Db = require('../db'); -const DynamicDns = require('../dynamic-dns'); -const Mailer = require('../mailer'); -const Recover = require('./recover'); -const Reset = require('./reset'); -const SignUp = require('./signup'); +module.exports = function (server) { + const api = new Hapi.Server(); -const log = CreateLogger('ApiApp'); - -module.exports = function (config) { - let api = Express(); - - api.config = config; - api.mailer = new Mailer(config.nodemailer, config.smtpFrom); - api.db = new Db(config.pg); - if (config.awsHostedZoneId) { - api.dynamicDns = new DynamicDns(config); - } - - api.use(Cors()); - api.use(BodyParser.json()); - api.use(BodyParser.urlencoded({ extended: true })); - - api.use((req, res, next) => { - req.log = log.child({ reqId: Uuid.v4() }); - next(); + api.connection({ + listener: server }); - api.get('/', (req, res, next) => { - res.json({ message: 'Welcome to the SpaceKit api.' }); - }); + const plugins = [Auth, Hello, Signup]; - api.post('/recover', Recover); - api.post('/reset', Reset); - api.post('/signup', SignUp); + api.register(plugins, (err) => { + /* $lab:coverage:off$ */ + if (err) { + throw err; + } + /* $lab:coverage:on$ */ + }); return api; }; diff --git a/lib/api/recover.js b/lib/api/recover.js deleted file mode 100644 index df38a9b..0000000 --- a/lib/api/recover.js +++ /dev/null @@ -1,112 +0,0 @@ -'use strict'; -const Async = require('async'); -const Bcrypt = require('bcrypt'); -const ValidEmail = require('email-validator').validate; -const Uuid = require('node-uuid'); - -module.exports = function Recover (req, res) { - let reply = { - success: false, - errors: [], - message: null - }; - - Async.auto({ - validate: function (done) { - if (!req.body.hasOwnProperty('email')) { - reply.errors.push('`email` is required'); - } else if (!ValidEmail(req.body.email)) { - reply.errors.push('`email` has an invalid format'); - } - - done(); - }, - replyImmediately: ['validate', function (done, results) { - // we reply immediatly with a generic response so we don't leak facts - - if (reply.errors.length) { - res.status(400); - res.json(reply); - return done(Error('Validation failed.')); - } - - reply.success = true; - reply.message = 'If that email address matched an account, ' + - 'an email will be sent with instructions.'; - - res.json(reply); - - done(); - }], - userLookup: ['replyImmediately', function (done, results) { - let email = req.body.email; - let query = 'SELECT id, username FROM users WHERE email = $1'; - - req.app.db.run(query, [email], (err, result) => { - if (err) { - return done(err); - } - - if (result.rows.length === 0) { - return done(Error('User not found.')); - } - - done(null, result.rows[0]); - }); - }], - resetToken: ['userLookup', function (done, results) { - let uuid = Uuid.v4(); - - Async.auto({ - salt: function (done) { - Bcrypt.genSalt(10, done); - }, - hash: ['salt', function (done, results) { - Bcrypt.hash(uuid, results.salt, done); - }] - }, (err, results) => { - if (err) { - return done(err); - } - - done(null, { - plain: uuid, - hash: results.hash - }); - }); - }], - saveToken: ['resetToken', function (done, results) { - let query = ` - UPDATE users - SET reset_token = $1, reset_expires = $2 - WHERE id = $3 - `; - let params = [ - results.resetToken.hash, - new Date(Date.now() + 10000000), - results.userLookup.id - ]; - - req.app.db.run(query, params, done); - }], - sendMail: ['saveToken', function (done, results) { - let emailOpts = { - subject: 'Reset your SpaceKit API key', - to: req.body.email - }; - let template = 'recover-api-key'; - let context = { - username: results.userLookup.username, - resetToken: results.resetToken.plain - }; - - req.app.mailer.sendEmail(emailOpts, template, context, done); - }] - }, (err, results) => { - if (err) { - req.log.error(err); - } - - // do nothing, we already completed the request - }); -}; diff --git a/lib/api/reset.js b/lib/api/reset.js deleted file mode 100644 index 8aabcf5..0000000 --- a/lib/api/reset.js +++ /dev/null @@ -1,135 +0,0 @@ -'use strict'; -const Async = require('async'); -const Bcrypt = require('bcrypt'); -const ValidEmail = require('email-validator').validate; -const Uuid = require('node-uuid'); - -module.exports = function Reset (req, res) { - let reply = { - success: false, - errors: [], - apiKey: null - }; - - Async.auto({ - validate: function (done) { - if (!req.body.hasOwnProperty('email')) { - reply.errors.push('`email` is required'); - } else if (!ValidEmail(req.body.email)) { - reply.errors.push('`email` has an invalid format'); - } - - if (!req.body.hasOwnProperty('token')) { - reply.errors.push('`token` is required'); - } - - if (reply.errors.length === 0) { - done(); - } else { - res.status(400); - done(Error('Validation failed.')); - } - }, - userLookup: ['validate', function (done, results) { - let query = ` - SELECT id, reset_token FROM users - WHERE email = $1 AND reset_expires > $2 - `; - let params = [ - req.body.email, - new Date() - ]; - - req.app.db.run(query, params, (err, result) => { - if (err) { - res.status(500); - reply.errors.push('exception encountered'); - return done(err); - } - - let failMessage = 'either the reset token is invalid ' + - 'or the email address is incorrect'; - - if (result.rows.length === 0) { - res.status(401); - reply.errors.push(failMessage); - return done(Error('User not found.')); - } - - let token = req.body.token; - let tokenHash = result.rows[0].reset_token; - - Bcrypt.compare(token, tokenHash, (err, pass) => { - if (err) { - res.status(500); - reply.errors.push('exception encountered'); - return done(err); - } - - if (!pass) { - res.status(401); - reply.errors.push(failMessage); - return done(Error('Bcrypt compare failed.')); - } - - done(null, result.rows[0]); - }); - }); - }], - apiKey: ['userLookup', function (done, results) { - let uuid = Uuid.v4(); - - Async.auto({ - salt: function (done) { - Bcrypt.genSalt(10, done); - }, - hash: ['salt', function (done, results) { - Bcrypt.hash(uuid, results.salt, done); - }] - }, (err, results) => { - if (err) { - res.status(500); - return done(err); - } - - done(null, { - plain: uuid, - hash: results.hash - }); - }); - }], - updateUser: ['apiKey', function (done, results) { - let query = ` - UPDATE users - SET - api_key = $1, - reset_token = NULL, - reset_expires = NULL - WHERE id = $2 - `; - let params = [ - results.apiKey.hash, - results.userLookup.id - ]; - - req.app.db.run(query, params, (err, result) => { - if (err) { - res.status(500); - reply.errors.push('exception encountered'); - return done(err); - } - - reply.success = true; - reply.apiKey = results.apiKey.plain; - - done(); - }); - }] - }, (err, results) => { - if (err) { - req.log.error(err); - } - - res.json(reply); - }); -}; diff --git a/lib/api/signup.js b/lib/api/signup.js index 0eb1dce..f9f1a9b 100644 --- a/lib/api/signup.js +++ b/lib/api/signup.js @@ -1,136 +1,88 @@ 'use strict'; const Async = require('async'); -const Bcrypt = require('bcrypt'); -const ValidEmail = require('email-validator').validate; -const Uuid = require('node-uuid'); - -module.exports = function SignUp (req, res) { - let reply = { - success: false, - errors: [], - apiKey: null - }; - - Async.auto({ - username: function (done, results) { - let validationError = Error('Username validation failed.'); - - if (!req.body.hasOwnProperty('username')) { - reply.errors.push('`username` is required'); - } else if (req.body.username.length < 4) { - reply.errors.push('`username` must be at least 4 characters'); - } else if (!/^[a-zA-Z0-9\-_]+$/.test(req.body.username)) { - reply.errors.push('`username` should only contain letters, numbers, \'-\', \'_\''); - } - - if (reply.errors.length > 0) { - res.status(400); - return done(validationError); - } - - let username = req.body.username; - let query = 'SELECT id FROM users WHERE username = $1'; - - req.app.db.run(query, [username], (err, result) => { - if (err) { - res.status(500); - reply.errors.push('exception encountered'); - return done(err); - } - - if (result.rows.length !== 0) { - res.status(400); - reply.errors.push(`\`username\` (${username}) already in use`); - return done(validationError); +const Boom = require('boom'); +const Joi = require('joi'); +const Secret = require('../secret'); +const Session = require('../db/session'); +const User = require('../db/user'); + +exports.register = function (server, options, next) { + server.route({ + method: 'POST', + path: '/signup', + config: { + validate: { + payload: { + username: Joi.string().token().lowercase().required(), + email: Joi.string().email().lowercase().required() } - - done(null, username); - }); - }, - email: ['username', function (done, results) { - let validationError = Error('Email validation failed.'); - - if (!req.body.hasOwnProperty('email')) { - reply.errors.push('`email` is required'); - } else if (!ValidEmail(req.body.email)) { - reply.errors.push('`email` has an invalid format'); - } - - if (reply.errors.length > 0) { - res.status(400); - return done(validationError); - } - - let email = req.body.email; - let query = 'SELECT id FROM users WHERE email = $1'; - - req.app.db.run(query, [email], (err, result) => { - if (err) { - res.status(500); - reply.errors.push('exception encountered'); - return done(err); + }, + pre: [{ + assign: 'usernameCheck', + method: function (request, reply) { + const username = request.payload.username; + + User.findOneByUsername(username, (err, user) => { + if (err) { + return reply(Boom.badImplementation('exception', err)); + } + + if (user) { + return reply(Boom.conflict('Username already in use.')); + } + + reply(true); + }); } - - if (result.rows.length !== 0) { - res.status(400); - reply.errors.push(`\`email\` (${email}) already in use`); - return done(validationError); + }, { + assign: 'emailCheck', + method: function (request, reply) { + const email = request.payload.email; + + User.findOneByEmail(email, (err, user) => { + if (err) { + return reply(Boom.badImplementation('exception', err)); + } + + if (user) { + return reply(Boom.conflict('Email already in use.')); + } + + reply(true); + }); } - - done(null, email); - }); - }], - apiKey: ['username', 'email', function (done, results) { - let uuid = Uuid.v4(); - + }] + }, + handler: function (request, reply) { Async.auto({ - salt: function (done) { - Bcrypt.genSalt(10, done); + user: function (done) { + const username = request.payload.username; + const email = request.payload.email; + + User.create(username, email, done); }, - hash: ['salt', function (done, results) { - Bcrypt.hash(uuid, results.salt, done); + authKey: function (done) { + Secret.createUuid(done); + }, + session: ['user', 'authKey', function (results, done) { + const userId = results.user.id; + const authKey = results.authKey.hash; + + Session.create(userId, authKey, done); }] }, (err, results) => { if (err) { - res.status(500); - return done(err); + return reply(Boom.badImplementation('exception', err)); } - done(null, { - plain: uuid, - hash: results.hash - }); + reply({ authKey: results.authKey.plain }); }); - }], - user: ['apiKey', function (done, results) { - let query = ` - INSERT INTO users (username, email, api_key) - VALUES ($1, $2, $3) - `; - let params = [ - results.username, - results.email, - results.apiKey.hash - ]; - - req.app.db.run(query, params, (err, result) => { - if (err) { - res.status(500); - reply.errors.push('exception encountered'); - return done(err); - } - - reply.success = true; - reply.apiKey = results.apiKey.plain; - - done(); - }); - }] - }, (err, results) => { - if (err) { - req.log.error(err); } - - res.json(reply); }); + + next(); +}; + +exports.register.attributes = { + name: 'signup' }; diff --git a/lib/config.js b/lib/config.js index 3b19252..d90316e 100644 --- a/lib/config.js +++ b/lib/config.js @@ -1,40 +1,108 @@ 'use strict'; +const Confidence = require('confidence'); +const LetsEncrypt = require('letsencrypt'); const Path = require('path'); -let defaultConfig = { - pg: null, // "postgres://username:password@host:port/dbname", - smtpUser: null, - smtpPass: null, - smtpHost: 'smtp.gmail.com', - smtpFrom: 'SpaceKit ', - awsHostedZoneId: null, - awsAccessKeyId: null, - awsSecretAccessKey: null, - awsRecordSetTtl: 1, - api: 'api', - web: 'www', - host: '127.0.0.1.nip.io', - // The following are usually only overridden for testing: - httpPort: 80, - httpsPort: 443 -}; - -let configFile = {}; +let conf = {}; try { - let filePath = Path.resolve(process.cwd(), 'spacekit-service.json'); - configFile = require(filePath); + const path = Path.resolve(process.cwd(), 'spacekit-service.json'); + conf = require(path); } catch (e) {} +conf = new Confidence.Store(conf); -let config = Object.assign({}, defaultConfig, configFile); +const criteria = { + env: process.env.NODE_ENV +}; -config.nodemailer = { - host: config.smtpHost, - port: config.smtpPort, - secure: true, - auth: { - user: config.smtpUser, - pass: config.smtpPass +const config = { + debug: { + logLevel: { + $filter: 'env', + test: 'fatal', + $default: conf.get('/logLevel') + } + }, + letsEncrypt: { + email: conf.get('/letsEncrypt/email'), + serverUrl: { + $filter: 'env', + test: LetsEncrypt.stagingServerUrl, + $default: LetsEncrypt.productionServerUrl + } + }, + postgres: { + uri: { + $filter: 'env', + test: 'postgres://postgres:mysecretpassword@localhost/spacekit_test', + $default: conf.get('/postgres') + } + }, + service: { + domain: { + $filter: 'env', + test: 'spacekit.io', + $default: conf.get('/service/domain') + }, + subdomains: { + $filter: 'env', + test: { + api: 'api.spacekit.io', + web: 'www.spacekit.io' + }, + $default: { + api: conf.get('/service/subdomains/api'), + web: conf.get('/service/subdomains/web') + } + }, + ports: { + http: { + $filter: 'env', + test: 8080, + $default: conf.get('/service/ports/http') + }, + https: { + $filter: 'env', + test: 8443, + $default: conf.get('/service/ports/https') + }, + range: { + $filter: 'env', + test: { + start: 9100, + end: 9199 + }, + $default: { + start: conf.get('/service/ports/range/start'), + end: conf.get('/service/ports/range/end') + } + } + } + }, + aws: { + hostedZoneId: conf.get('/aws/hostedZoneId'), + accessKeyId: conf.get('/aws/accessKeyId'), + secretAccessKey: conf.get('/aws/secretAccessKey'), + recordSetTtl: conf.get('/aws/recordSetTtl') + }, + mail: { + from: { + name: conf.get('/mail/from/name'), + address: conf.get('/mail/from/address') + } + }, + nodemailer: { + host: conf.get('/smtp/host'), + port: conf.get('/smtp/port'), + secure: conf.get('/smtp/secure'), + auth: { + user: conf.get('/smtp/user'), + pass: conf.get('/smtp/pass') + } } }; -module.exports = config; +const store = new Confidence.Store(config); + +exports.get = function (key) { + return store.get(key, criteria); +}; diff --git a/lib/create-logger.js b/lib/create-logger.js index d8e96b4..a875311 100644 --- a/lib/create-logger.js +++ b/lib/create-logger.js @@ -1,9 +1,11 @@ 'use strict'; const Bunyan = require('bunyan'); +const Config = require('./config'); const Path = require('path'); const log = Bunyan.createLogger({ name: 'SpaceKitService', + level: Config.get('/debug/logLevel'), streams: [{ stream: process.stdout }, { diff --git a/lib/db.js b/lib/db.js deleted file mode 100644 index 28e1184..0000000 --- a/lib/db.js +++ /dev/null @@ -1,47 +0,0 @@ -'use strict'; -const CreateLogger = require('./create-logger'); -const Pg = require('pg').native; - -const log = CreateLogger('Db'); - -class Db { - constructor (connectionString) { - this.connectionString = connectionString; // may be null if unconfigured - } - - run (query, params, callback) { - Pg.connect(this.connectionString, (err, client, done) => { - if (err) { - return callback(err); - } - - client.query(query, params, (err, result) => { - done(); // releases client - - callback(err, result); - }); - }); - } - - /** - * Increment a statistic in the database. - * - * Every stat is tracked on a daily basis, for graphing and aggregation. - * This functionality requires PostgreSQL 9.5 or later. - */ - incrementStat (key, increment) { - let period = new Date().toISOString().slice(0, 10); // 2016-01-01 - this.run(` - INSERT INTO stats (period, key, value) - VALUES ($1, $2, $3) - ON CONFLICT (period, key) - DO UPDATE SET value = stats.value + EXCLUDED.value - `, [period, key, increment], (err, result) => { - if (err) { - log.warn('Unable to save stat:', key, increment, err); - } - }); - } -} - -module.exports = Db; diff --git a/lib/db/index.js b/lib/db/index.js new file mode 100644 index 0000000..b9569e1 --- /dev/null +++ b/lib/db/index.js @@ -0,0 +1,62 @@ +'use strict'; +const Async = require('async'); +const Config = require('../config'); +const Pg = require('pg').native; + +const pgUri = Config.get('/postgres/uri'); + +class Db { + static runForOne (command, params, callback) { + this.run(command, params, (err, results) => { + if (err) { + return callback(err); + } + + if (results.rows.length === 0) { + return callback(); + } + + callback(null, results.rows[0]); + }); + } + + static run (command, params, callback) { + this.pg.connect(pgUri, (err, client, done) => { + if (err) { + return callback(err); + } + + client.query(command, params, (err, result) => { + done(); // releases client + + callback(err, result); + }); + }); + } + + static runSeries (commands, callback) { + this.pg.connect(pgUri, (err, client, done) => { + if (err) { + return callback(err); + } + + const queries = commands.map((command) => { + return function (cb) { + const cmd = typeof command === 'string' ? command : command[0]; + const params = typeof command === 'string' ? [] : command[1]; + client.query(cmd, params, cb); + }; + }); + + Async.series(queries, (err, results) => { + done(); // releases client + + callback(err, results); + }); + }); + } +} + +Db.pg = Pg; + +module.exports = Db; diff --git a/lib/db/scaffold.js b/lib/db/scaffold.js new file mode 100644 index 0000000..0b106de --- /dev/null +++ b/lib/db/scaffold.js @@ -0,0 +1,49 @@ +'use strict'; +const Db = require('./index'); +const Session = require('./session'); +const Stat = require('./stat'); +const User = require('./user'); + +class Scaffold { + static setup (callback) { + // order matters here; + // missing relations will cause failure + const commands = [ + User.scaffold.setup, + Session.scaffold.setup, + Stat.scaffold.setup + ]; + + Array.prototype.push.apply(commands, Stat.scaffold.constraints); + + this.tearDown((err, results) => { + if (err) { + return callback(err); + } + + Db.runSeries(commands, callback); + }); + } + + static clear (callback) { + const commands = [ + Session.scaffold.clear, + Stat.scaffold.clear, + User.scaffold.clear + ]; + + Db.runSeries(commands, callback); + } + + static tearDown (callback) { + const commands = [ + Session.scaffold.tearDown, + Stat.scaffold.tearDown, + User.scaffold.tearDown + ]; + + Db.runSeries(commands, callback); + } +} + +module.exports = Scaffold; diff --git a/lib/db/session.js b/lib/db/session.js new file mode 100644 index 0000000..56f1355 --- /dev/null +++ b/lib/db/session.js @@ -0,0 +1,54 @@ +'use strict'; +const Async = require('async'); +const Db = require('./index'); +const Secret = require('../secret'); + +class Session { + static create (userId, authKey, callback) { + const query = ` + INSERT INTO sessions (user_id, auth_key) + VALUES ($1, $2) + RETURNING * + `; + const params = [userId, authKey]; + + Db.runForOne(query, params, callback); + } + + static authenticate (username, authKey, callback) { + const query = ` + SELECT sessions.auth_key + FROM sessions + LEFT JOIN users ON sessions.user_id = users.id + WHERE users.username = $1 + `; + const params = [username]; + + Db.run(query, params, (err, result) => { + if (err) { + return callback(err); + } + + const iteratee = function (row, done) { + Secret.compare(authKey, row.auth_key, done); + }; + + Async.some(result.rows, iteratee, callback); + }); + } +} + +Session.scaffold = { + setup: ` + CREATE TABLE IF NOT EXISTS sessions ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users ON DELETE CASCADE, + auth_key TEXT, + created TIMESTAMP WITHOUT TIME ZONE DEFAULT NOW() + ) + `, + clear: 'DELETE FROM sessions', + tearDown: 'DROP TABLE IF EXISTS sessions' +}; + +module.exports = Session; diff --git a/lib/db/stat.js b/lib/db/stat.js new file mode 100644 index 0000000..da6d960 --- /dev/null +++ b/lib/db/stat.js @@ -0,0 +1,40 @@ +'use strict'; +const Db = require('./index'); + +class Stat { + /** + * Increment a statistic in the database. + * + * Every stat is tracked on a daily basis, for graphing and aggregation. + * This functionality requires PostgreSQL 9.5 or later. + */ + static increment (key, increment, callback) { + const query = ` + INSERT INTO stats (period, key, value) + VALUES ($1, $2, $3) + ON CONFLICT (period, key) + DO UPDATE SET value = stats.value + EXCLUDED.value + `; + const period = new Date().toISOString().slice(0, 10); // 2016-01-01 + const params = [period, key, increment]; + + Db.run(query, params, callback); + } +} + +Stat.scaffold = { + setup: ` + CREATE TABLE IF NOT EXISTS stats ( + period DATE, + key TEXT, + value BIGINT DEFAULT 0 + ) + `, + clear: 'DELETE FROM stats', + tearDown: 'DROP TABLE IF EXISTS stats', + constraints: [ + 'ALTER TABLE stats ADD UNIQUE (period, key)' + ] +}; + +module.exports = Stat; diff --git a/lib/db/user.js b/lib/db/user.js new file mode 100644 index 0000000..dc029c6 --- /dev/null +++ b/lib/db/user.js @@ -0,0 +1,68 @@ +'use strict'; +const Db = require('./index'); + +class User { + static create (username, email, callback) { + const query = ` + INSERT INTO users (username, email) + VALUES ($1, $2) + RETURNING * + `; + const params = [username, email]; + + Db.runForOne(query, params, callback); + } + + static findOneByUsername (username, callback) { + const query = 'SELECT * FROM users WHERE username = $1'; + const params = [username]; + + Db.runForOne(query, params, callback); + } + + static findOneByEmail (email, callback) { + const query = 'SELECT * FROM users WHERE email = $1'; + const params = [email]; + + Db.runForOne(query, params, callback); + } + + static findOneByCredentials (username, email, callback) { + const query = ` + SELECT * FROM users + WHERE username = $1 AND email = $2 + `; + const params = [username, email]; + + Db.runForOne(query, params, callback); + } + + static setChallenge (id, challenge, expires, callback) { + const query = ` + UPDATE users + SET challenge = $2, challenge_expires = $3 + WHERE id = $1 + RETURNING * + `; + const params = [id, challenge, expires]; + + Db.runForOne(query, params, callback); + } +} + +User.scaffold = { + setup: ` + CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + username TEXT UNIQUE, + email TEXT UNIQUE, + challenge TEXT, + challenge_expires TIMESTAMP WITHOUT TIME ZONE, + created TIMESTAMP WITHOUT TIME ZONE DEFAULT NOW() + ) + `, + clear: 'DELETE FROM users', + tearDown: 'DROP TABLE IF EXISTS users' +}; + +module.exports = User; diff --git a/lib/dynamic-dns.js b/lib/dynamic-dns.js index 2b75313..8a6184a 100644 --- a/lib/dynamic-dns.js +++ b/lib/dynamic-dns.js @@ -1,50 +1,73 @@ 'use strict'; const AWS = require('aws-sdk'); - +const Config = require('./config'); const CreateLogger = require('./create-logger'); +const Dns = require('dns'); + +AWS.config.update({ + accessKeyId: Config.get('/aws/accessKeyId'), + secretAccessKey: Config.get('/aws/secretAccessKey') +}); const log = CreateLogger('DynamicDns'); +const route53 = new AWS.Route53(); +const dnsCache = new Map(); +const apiHostname = Config.get('/service/subdomains/api'); -/** - * A wrapper for Amazon Route 53's DNS service, for the sole purpose of - * upserting individual hostname records. - */ class DynamicDns { - constructor (config) { - this.config = config; - - // TODO: since this is global, should it be done in the SpaceKitService - // constructor? - AWS.config.update({ - accessKeyId: config.awsAccessKeyId, - secretAccessKey: config.awsSecretAccessKey - }); + /** + * Resolves a hostname to IPv4, caches results. + */ + static resolveDns (hostname, callback) { + const cacheHit = dnsCache.get(hostname); + + if (cacheHit) { + return callback(null, cacheHit); + } + + Dns.resolve4(hostname, (err, addresses) => { + if (err) { + return callback(err); + } + + dnsCache.set(hostname, addresses[0]); - this.route53 = new AWS.Route53(); + callback(null, addresses[0]); + }); } - upsert (options, callback) { - let params = { - ChangeBatch: { - Changes: [{ - Action: 'UPSERT', - ResourceRecordSet: { - Name: options.hostname, - Type: options.recordType, - ResourceRecords: [{ - Value: options.recordValue - }], - TTL: this.config.awsRecordSetTtl - } - }], - Comment: options.hostname + ' -> ' + options.recordValue - }, - HostedZoneId: this.config.awsHostedZoneId - }; - - log.info(`${options.hostname} => ${options.recordValue}`); - - this.route53.changeResourceRecordSets(params, callback); + /** + * Upsert individual hostname records using AWS Route53. + */ + static upsert (hostname, callback) { + DynamicDns.resolveDns(apiHostname, (err, ipAddress) => { + if (err) { + log.error(err, 'dns resolve error'); + return callback(err); + } + + const params = { + ChangeBatch: { + Changes: [{ + Action: 'UPSERT', + ResourceRecordSet: { + Name: hostname, + Type: 'A', + ResourceRecords: [{ + Value: ipAddress + }], + TTL: Config.get('/aws/recordSetTtl') + } + }], + Comment: hostname + ' -> ' + ipAddress + }, + HostedZoneId: Config.get('/aws/hostedZoneId') + }; + + log.info(`${hostname} => ${ipAddress}`); + + route53.changeResourceRecordSets(params, callback); + }); } } diff --git a/lib/emails/auth-challenge.hbs.md b/lib/emails/auth-challenge.hbs.md new file mode 100644 index 0000000..9a2a141 --- /dev/null +++ b/lib/emails/auth-challenge.hbs.md @@ -0,0 +1,12 @@ +### Auth request + +We received a request to authenticate with your account. +You'll need this pin to complete the process. If you did +not initiate this request, please ignore this email. + +__Challenge pin:__ +{{pin}} + +Thank you, + +The SpaceKit Team diff --git a/lib/emails/recover-api-key.hbs.md b/lib/emails/recover-api-key.hbs.md deleted file mode 100644 index 306e458..0000000 --- a/lib/emails/recover-api-key.hbs.md +++ /dev/null @@ -1,14 +0,0 @@ -### Reset your api key - -We received a request to recover the api key for your account. -You'll need this reset token to do it. - -__Username:__ -{{username}} - -__Reset token:__ -{{resetToken}} - -Thank you, - -The SpaceKit Team diff --git a/lib/index.js b/lib/index.js index 8df45c8..b10b499 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,18 +1,15 @@ 'use strict'; -const Bcrypt = require('bcrypt'); -const Dns = require('dns'); -const Https = require('https'); -const WebSocketServer = require('ws').Server; - -const ApiApp = require('./api'); +const ApiServer = require('./api/index'); const CreateLogger = require('./create-logger'); -const CreateNetProxyServer = require('./net-proxy-server'); -const CreateTlsProxyServer = require('./tls-proxy-server'); -const Db = require('./db'); +const Config = require('./config'); const DynamicDns = require('./dynamic-dns'); +const Https = require('https'); +const NetProxy = require('./net-proxy'); +const Session = require('./db/session'); const TlsCertificate = require('./tls-certificate'); -const WebApp = require('./www'); +const WebServer = require('./web/index'); const WebSocketRelay = require('./web-socket-relay'); +const WebSocketServer = require('ws').Server; const log = CreateLogger('SpaceKitService'); @@ -20,8 +17,9 @@ const log = CreateLogger('SpaceKitService'); * The SpaceKitService listens for TLS connections. Depending on the hostname * (provided by SNI) of the connection, we'll do the following: * - * If the hostname of the incoming connection is the hostname of SpaceKitService, - * we will handle the request ourselves (either a WebSocket or HTTPS request). + * If the hostname of the incoming connection is the hostname of + * SpaceKitService, we will handle the request ourselves (either a WebSocket or + * HTTPS request). * * Otherwise, we will transparently proxy that connection to one of the * connected client relays serving the requested hostname (if available). @@ -30,45 +28,46 @@ const log = CreateLogger('SpaceKitService'); * DNS records to the appropriate client relay. */ class SpaceKitService { - constructor (config) { - this.host = config.host; // "spacekit.io" - this.apiHostname = `${config.api}.${config.host}`; - this.webHostname = `${config.web}.${config.host}`; + constructor () { + this.domain = Config.get('/service/domain'); + this.apiHostname = Config.get('/service/subdomains/api'); + this.webHostname = Config.get('/service/subdomains/web'); this.certificates = new Map(); this.servers = []; - this.db = new Db(config.pg); this.relays = new Map(); // hostname -> WebSocketRelay - this.dnsCache = new Map(); // hostname -> IPv4 - let ports = [config.httpsPort]; - for (let i = 8000; i <= 8999; i++) { - ports.push(i); + // Fill the tlsPorts array. + const tlsPorts = []; + const rangeStart = Config.get('/service/ports/range/start'); + const rangeEnd = Config.get('/service/ports/range/end'); + tlsPorts.push(Config.get('/service/ports/https')); + for (let i = rangeStart; i <= rangeEnd; i++) { + tlsPorts.push(i); } - ports.forEach((port) => { - // Listen for any incoming Tls connections. - let tlsServer = CreateTlsProxyServer(this.handleTlsConnection.bind(this, port)); + // Listen for incoming Tls connections. + tlsPorts.forEach((port) => { + const tlsServer = NetProxy.createTlsServer(this.handleTlsConnection.bind(this, port)); tlsServer.listen(port); this.servers.push(tlsServer); }); - // Listen for any incoming Net connections. - let plainServer = CreateNetProxyServer(this.handlePlainTextConnection.bind(this)); - plainServer.listen(config.httpPort); - this.servers.push(plainServer); + // Listen for incoming Tcp connections. + const tcpServer = NetProxy.createTcpServer(this.handleTcpConnection.bind(this)); + tcpServer.listen(Config.get('/service/ports/http')); + this.servers.push(tcpServer); // An HTTPS server will handle api requests and relay WebSocket upgrades. // Note: This server doesn't actually bind itself to a port; we hand it // established connections from a TLS proxy handler. - this.apiServer = Https.createServer({ - SNICallback: (serverName, cb) => { - this.getCertificate(serverName).processSniCallback(cb); - } - }, ApiApp(config)); + this.apiHttpServer = Https.createServer({ + SNICallback: this.sniCallback.bind(this) + }); + this.apiServer = ApiServer(this.apiHttpServer); // A WebSocket server will handle relay client connections for the api // server. - this.wss = new WebSocketServer({ server: this.apiServer }); + this.wss = new WebSocketServer({ server: this.apiHttpServer }); this.wss.on('connection', this.authenticateRelayConnection.bind(this)); this.wss.on('headers', (headers) => { headers['Access-Control-Allow-Origin'] = '*'; @@ -80,61 +79,73 @@ class SpaceKitService { // An HTTPS server will handle website requests. Note: This server doesn't // actually bind itself to a port; we hand it established connections from // a TLS proxy handler. - this.webServer = Https.createServer({ - SNICallback: (serverName, cb) => { - this.getCertificate(serverName).processSniCallback(cb); - } - }, WebApp(config)); - - // Configure the DNS updater, if applicable. - if (config.awsHostedZoneId) { - this.dynamicDns = new DynamicDns(config); - } + this.webHttpServer = Https.createServer({ + SNICallback: this.sniCallback.bind(this) + }); + this.webServer = WebServer(this.webHttpServer); log.info('the service has started'); - - this.db.incrementStat('startups', 1); } + /** + * Closes all the servers + */ close () { this.servers.forEach((server) => { server.close(); }); } + /** + * Gets the TlsCertificate for a hostname or creates one if it doesn't exist. + */ getCertificate (hostname) { - let cert = this.certificates.get(hostname); - if (!cert) { - cert = new TlsCertificate(hostname); - this.certificates.set(hostname, cert); + const cacheHit = this.certificates.get(hostname); + + if (cacheHit) { + return cacheHit; } + + const cert = new TlsCertificate(hostname); + + this.certificates.set(hostname, cert); + return cert; } + /** + * Handles the SNI callback for our https servers + */ + sniCallback (serverName, cb) { + this.getCertificate(serverName).getSecureContext(cb); + } + /** * Handle a TLS connection that needs to be forwarded to `hostname`. * * If this connection's hostname is one of SpaceKit's services, we forward * the request to our own HTTPS server. * - * Otherwise, pass the connection onto a client relay for that hostname, - * if one is available. + * Otherwise, pass the connection onto a client relay for that hostname, if + * one is available. */ handleTlsConnection (serverPort, socket, hostname) { log.info({ for: hostname }, 'new Tls connection'); if (hostname === this.apiHostname) { - this.apiServer.emit('connection', socket); - } else if (hostname === this.webHostname || hostname === this.host) { - // The WebApp ensures that non-"www." gets redirected to "www." - this.webServer.emit('connection', socket); + this.apiHttpServer.emit('connection', socket); + } else if (hostname === this.webHostname || hostname === this.domain) { + // this.webServer will ensure the "www." subdomain redirect + this.webHttpServer.emit('connection', socket); } else { - let relay = this.relays.get(hostname); - if (relay) { - relay.addSocket(socket, hostname, serverPort); - } else { - socket.end(); // This is a TLS connection, we can't end it with plaintext HTTP + const relay = this.relays.get(hostname); + + if (!relay) { + socket.end(); + return; } + + relay.addSocket(socket, hostname, serverPort); } } @@ -148,28 +159,31 @@ class SpaceKitService { * relay, so that users can run providers like Let's Encrypt themselves to * receive certificates. */ - handlePlainTextConnection (socket, hostname, path) { - log.info({ for: hostname, path: path }, 'new Net connection'); + handleTcpConnection (socket, hostname, path) { + log.info({ for: hostname, path: path }, 'new tcp connection'); - if (hostname === this.host) { - // Redirect "spacekit.io" to "www.spacekit.io" + if (hostname === this.domain) { + // redirect to https socket.end(`HTTP/1.1 301\r\nLocation: https://${this.webHostname}${path}\r\n\r\n`); } else if (path.startsWith('/.well-known/acme-challenge/')) { - // Forward ACME challenges, including our own web and api handlers. + // handle ACME challenges for our own web and api servers if (hostname === this.apiHostname || hostname === this.webHostname) { - // XXX: forward to TLS cert generator - this.getCertificate(hostname).handleAcmeChallengeSocket(socket, path); + const cert = this.getCertificate(hostname); + + socket.end('HTTP/1.0 200 OK\r\n\r\n' + cert.challengeValue); } else { - let relay = this.relays.get(hostname); - if (relay) { - relay.addSocket(socket, hostname, 80); - } else { - let message = 'Relay not connected'; - socket.end(`HTTP/1.1 500 ${message}\r\n\r\n${message}`); + // relays handle their own challenge requests + const relay = this.relays.get(hostname); + + if (!relay) { + socket.end(); + return; } + + relay.addSocket(socket, hostname, Config.get('/service/ports/http')); } } else { - // Otherwise, redirect everything to HTTPS. + // redirect all other requests to https socket.end(`HTTP/1.1 301\r\nLocation: https://${hostname}${path}\r\n\r\n`); } } @@ -178,126 +192,65 @@ class SpaceKitService { * Authenticate an incoming connection from a client relay. */ authenticateRelayConnection (webSocket) { - let subdomain = webSocket.upgradeReq.headers['x-spacekit-subdomain']; - let username = webSocket.upgradeReq.headers['x-spacekit-username']; - let apiKey = webSocket.upgradeReq.headers['x-spacekit-apikey']; - let hostname = `${subdomain}.${username}.${this.host}`; - let existingRelay = this.relays.get(hostname); + const subdomain = webSocket.upgradeReq.headers['x-spacekit-subdomain']; + const username = webSocket.upgradeReq.headers['x-spacekit-username']; + const authKey = webSocket.upgradeReq.headers['x-spacekit-authkey']; + const hostname = `${subdomain}.${username}.${this.domain}`; + const existingRelay = this.relays.get(hostname); webSocket.log = log.child({ for: hostname }); - webSocket.on('error', (err) => { - webSocket.log.error({ err: err }, 'relay web socket error event'); - }); - if (existingRelay) { - webSocket.log.info('existing relay found, closing'); + webSocket.log.info('existing relay found, closing it'); existingRelay.webSocket.close(1001); // 1001 (going away) } - if (!this.db.connectionString) { - webSocket.log.info('Bypassing authentication.'); - this.handleRelayConnection(webSocket, hostname); - return; - } - - let query = 'SELECT id, api_key FROM users WHERE username = $1'; - - this.db.run(query, [username], (err, result) => { + Session.authenticate(username, authKey, (err, success) => { if (err) { - webSocket.log.error(err, 'relay auth failed (db query error)'); + webSocket.log.error(err, 'relay auth failed (query error)'); return webSocket.close(); } - if (result.rows.length === 0) { - webSocket.log.info('relay auth failed (user not found)'); - return webSocket.close(); + if (success === false) { + webSocket.log.info('relay auth failed'); + webSocket.close(); + return; } - Bcrypt.compare(apiKey, result.rows[0].api_key, (err, pass) => { - if (err) { - webSocket.log.error(err, 'relay auth failed (bcrypt compare error)'); - return webSocket.close(); - } - - if (!pass) { - webSocket.log.info('relay auth failed (api key incorrect)'); - return webSocket.close(); - } - - webSocket.log.info('relay auth success'); + webSocket.log.info('relay auth success'); - this.handleRelayConnection(webSocket, hostname); - }); + this.handleRelayConnection(webSocket, hostname); }); } /** - * Handle an incoming connection from a client relay. + * Handle an authenticated connection from a client relay. * * The webSocket here will send events to any TLS sockets it is associated - * with. (That magic happens in WebSocketRelay.) + * with. (that magic happens in WebSocketRelay) * - * If we're configured to update DNS, do so now. + * Finally, update DNS records. */ handleRelayConnection (webSocket, hostname) { - let relay = new WebSocketRelay(webSocket); + const relay = new WebSocketRelay(webSocket); this.relays.set(hostname, relay); - this.db.incrementStat('connections', 1); - - let bytesTransferred = 0; - relay.on('bytes', (count) => { - bytesTransferred += count; + webSocket.on('error', (err) => { + webSocket.log.error({ err: err }, 'relay web socket error event'); }); webSocket.on('close', () => { this.relays.delete(hostname); - this.db.incrementStat('bytes', bytesTransferred); }); - if (this.dynamicDns) { - this.resolveDns(this.apiHostname, (err, ipAddress) => { - if (err) { - log.error(err, 'dns resolve error'); - return webSocket.close(); - } else { - let options = { - hostname: hostname, - recordType: 'A', - recordValue: ipAddress - }; - - this.dynamicDns.upsert(options, (err, data) => { - if (err) { - log.error(err, 'dynamic dns error'); - return webSocket.close(); - } - - log.info('dns upsert success'); - }); - } - }); - } - } - - /** - * Resolves a hostname to IPv4, caches results. - */ - resolveDns (hostname, callback) { - if (this.dnsCache.get(hostname)) { - return callback(null, this.dnsCache.get(hostname)); - } - - Dns.resolve4(hostname, (err, addresses) => { + DynamicDns.upsert(hostname, (err, data) => { if (err) { - return callback(err); + log.error(err, 'dynamic dns error'); + return webSocket.close(); } - this.dnsCache.set(hostname, addresses[0]); - - callback(null, addresses[0]); + log.info('dynamic dns upsert success'); }); } } diff --git a/lib/mailer.js b/lib/mailer.js index 4934ace..e35790e 100644 --- a/lib/mailer.js +++ b/lib/mailer.js @@ -1,46 +1,45 @@ 'use strict'; +const Config = require('./config'); const Fs = require('fs'); const Handlebars = require('handlebars'); const Markdown = require('nodemailer-markdown').markdown; const Nodemailer = require('nodemailer'); const Path = require('path'); -class Mailer { - constructor (nodemailerConfig, smtpFrom) { - this.smtpFrom = smtpFrom; - this.templateCache = {}; - this.transport = Nodemailer.createTransport(nodemailerConfig); - this.transport.use('compile', Markdown({ useEmbeddedImages: true })); - } +const templateCache = {}; +const transport = Nodemailer.createTransport(Config.get('/nodemailer')); - renderTemplate (signature, context, callback) { - if (this.templateCache[signature]) { - return callback(null, this.templateCache[signature](context)); +transport.use('compile', Markdown({ useEmbeddedImages: true })); + +class Mailer { + static renderTemplate (signature, context, callback) { + if (templateCache[signature]) { + return callback(null, templateCache[signature](context)); } - let filePath = Path.resolve(__dirname, `emails/${signature}.hbs.md`); - let options = { encoding: 'utf-8' }; + const filePath = Path.resolve(__dirname, `emails/${signature}.hbs.md`); + const options = { encoding: 'utf-8' }; Fs.readFile(filePath, options, (err, source) => { if (err) { return callback(err); } - this.templateCache[signature] = Handlebars.compile(source); - callback(null, this.templateCache[signature](context)); + templateCache[signature] = Handlebars.compile(source); + callback(null, templateCache[signature](context)); }); } - sendEmail (options, template, context, callback) { + static sendEmail (options, template, context, callback) { this.renderTemplate(template, context, (err, content) => { if (err) { return callback(err); } - options.from = this.smtpFrom; + options.from = Config.get('/mail/from'); options.markdown = content; - this.transport.sendMail(options, callback); + transport.sendMail(options, callback); }); } } diff --git a/lib/net-proxy-server.js b/lib/net-proxy-server.js deleted file mode 100644 index 7b3b2f1..0000000 --- a/lib/net-proxy-server.js +++ /dev/null @@ -1,75 +0,0 @@ -'use strict'; -const Net = require('net'); - -const CreateLogger = require('./create-logger'); - -const log = CreateLogger('CreateNetProxyServer'); - -/** - * Create a server that extracts the hostname and path from incoming Net - * connections, puts the data back on the socket, and hands you back the socket - * and hostname. - * - * @param {function(socket, hostname, path)} connectionHandler - * @return {Net.Server} - */ -function createNetProxyServer (connectionHandler) { - return Net.createServer((socket) => { - socket.on('error', (err) => { - log.error(err, 'proxied socket error'); - }); - - let head = ''; - - socket.on('data', (data) => { - head = head + data.toString('ascii'); - - try { - let hostname = parseHostFromHeader(head); - - if (hostname) { - let path = parsePathFromHeader(head); - socket.removeAllListeners('data'); - socket.pause(); - socket.unshift(new Buffer(head, 'ascii')); - connectionHandler(socket, hostname, path); - socket.resume(); - } else { - // Waiting for more data. - } - } catch (err) { - log.error(err, 'parsing exception'); - let message = 'Parsing exception'; - socket.end(`HTTP/1.1 500 ${message}\r\n\r\n${message}`); - } - }); - }); -} - -function parsePathFromHeader (head) { - let pathMatch = /^[A-Za-z]+\s+(.*?)\s/.exec(head); - - if (pathMatch) { - return pathMatch[1]; - } else if (head.length > 8 * 1024) { - throw new Error('header too long'); - } else { - return ''; - } -} - -function parseHostFromHeader (head) { - let hostMatch = /^Host:\s*(.*?)\r\n/im.exec(head); - - if (hostMatch) { - return hostMatch[1]; - } else if (head.length > 8 * 1024) { - throw new Error('header too long'); - } else if (head.indexOf('\r\n\r\n') !== -1) { - throw new Error('no hostname provided'); - } else { - return null; - } -} - -module.exports = createNetProxyServer; diff --git a/lib/net-proxy.js b/lib/net-proxy.js new file mode 100644 index 0000000..25ca87f --- /dev/null +++ b/lib/net-proxy.js @@ -0,0 +1,118 @@ +'use strict'; +const CreateLogger = require('./create-logger'); +const Net = require('net'); +const Sni = require('sni'); + +const log = CreateLogger('NetProxy'); + +class NetProxy { + /** + * Create a server that extracts the hostname from incoming TLS (SNI) + * connections, puts the data back on the socket, and hands you back the + * socket and hostname. + * + * @param {function(socket, hostname)} connectionHandler + * @return {Net.Server} + */ + static createTlsServer (connectionHandler) { + const server = Net.createServer((socket) => { + socket.on('error', (err) => { + log.error(err, 'proxied socket error'); + }); + + socket.once('data', (initialData) => { + socket.pause(); + socket.unshift(initialData); + + const hostname = Sni(initialData); + + connectionHandler(socket, hostname); + + socket.resume(); + }); + }); + + server.on('error', (err) => { + log.error(err, 'tls server error'); + }); + + return server; + } + + /** + * Create a server that extracts the hostname and path from incoming tcp + * connections, puts the data back on the socket, and hands you back the + * socket and hostname. + * + * @param {function(socket, hostname, path)} connectionHandler + * @return {Net.Server} + */ + static createTcpServer (connectionHandler) { + const server = Net.createServer((socket) => { + socket.on('error', (err) => { + log.error(err, 'proxied socket error'); + }); + + let head = ''; + + socket.on('data', (data) => { + head += data.toString('ascii'); + + const hostname = NetProxy.parseHostFromHeader(head); + + if (hostname === undefined) { + const message = 'Parsing exception'; + socket.end(`HTTP/1.1 500 ${message}\r\n\r\n${message}`); + } else if (hostname === null) { + // waiting for more data + } else { + const path = NetProxy.parsePathFromHeader(head); + + socket.removeAllListeners('data'); + socket.pause(); + socket.unshift(new Buffer(head, 'ascii')); + + connectionHandler(socket, hostname, path); + + socket.resume(); + } + }); + }); + + server.on('error', (err) => { + log.error(err, 'tcp server error'); + }); + + return server; + } + + static parseHostFromHeader (head) { + const hostMatch = /^Host:\s*(.*?)\r\n/im.exec(head); + + if (hostMatch) { + return hostMatch[1]; + } else if (head.length > 8 * 1024) { + const err = Error('header too long'); + log.error(err, 'potentially malicious socket'); + return undefined; + } else if (head.indexOf('\r\n\r\n') !== -1) { + const err = Error('no hostname provided'); + log.error(err, 'potentially malicious socket'); + return undefined; + } else { + return null; + } + } + + static parsePathFromHeader (head) { + const pathMatch = /^[A-Za-z]+\s+(.*?)\s/.exec(head); + + if (pathMatch) { + return pathMatch[1]; + } else { + return ''; + } + } +} + +module.exports = NetProxy; diff --git a/lib/secret.js b/lib/secret.js new file mode 100644 index 0000000..b16720c --- /dev/null +++ b/lib/secret.js @@ -0,0 +1,50 @@ +'use strict'; +const Async = require('async'); +const Bcrypt = require('bcrypt'); +const Crypto = require('crypto'); +const Uuid = require('node-uuid'); + +class Secret { + static createUuid (callback) { + this.hash(Uuid.v4(), callback); + } + + static createPin (callback) { + const rBytes = Crypto.randomBytes(3).toString('hex'); + const rNum = parseInt(rBytes, 16) / Math.pow(256, 3); + const pin = Math.floor(rNum * 899999 + 100000); + + this.hash(pin, callback); + } + + static hash (value, callback) { + value = String(value).toString(); + + Async.auto({ + salt: function (done) { + Bcrypt.genSalt(10, done); + }, + hash: ['salt', function (results, done) { + Bcrypt.hash(value, results.salt, done); + }] + }, (err, results) => { + if (err) { + return callback(err); + } + + callback(null, { + plain: value, + hash: results.hash + }); + }); + } + + static compare (plain, hash, callback) { + plain = String(plain).toString(); + hash = String(hash).toString(); + + Bcrypt.compare(plain, hash, callback); + } +} + +module.exports = Secret; diff --git a/lib/tls-certificate.js b/lib/tls-certificate.js index 9e1d4af..63f1c23 100644 --- a/lib/tls-certificate.js +++ b/lib/tls-certificate.js @@ -1,156 +1,164 @@ 'use strict'; - +const Config = require('./config'); +const CreateLogger = require('./create-logger'); +const LetsEncrypt = require('letsencrypt'); +const Path = require('path'); const Pem = require('pem'); const Tls = require('tls'); -const CreateLogger = require('./create-logger'); const log = CreateLogger('TlsCertificate'); -let LetsEncrypt; -if (process.env.NODE_TLS_REJECT_UNAUTHORIZED !== '0') { - LetsEncrypt = require('letsencrypt'); -} else { - LetsEncrypt = { - create () { - return { - fetch: getSelfSignedCertificate, - register: getSelfSignedCertificate - }; - } - }; -} - class TlsCertificate { - constructor (hostname) { this.hostname = hostname; this._pems = null; this.RENEW_IF_EXPIRES_WITHIN_MS = 1000 * 60 * 60 * 24 * 7; - this.challengeValue = null; - this.le = LetsEncrypt.create({ + const leConfig = { server: LetsEncrypt.productionServerUrl, - configDir: './certs', + configDir: Path.resolve(process.cwd(), 'certs'), privkeyPath: ':config/live/:hostname/privkey.pem', fullchainPath: ':config/live/:hostname/fullchain.pem', certPath: ':config/live/:hostname/cert.pem', chainPath: ':config/live/:hostname/chain.pem', debug: false - }, { - setChallenge: (args, key, value, cb) => { + }; + const leHandlers = { + setChallenge: (args, key, value, callback) => { this.challengeValue = value; - cb(null); + callback(null); }, - getChallenge: (args, key, cb) => { - cb(null, this.challengeValue); + getChallenge: (args, key, callback) => { + callback(null, this.challengeValue); }, - removeChallenge: (args, key, cb) => { + removeChallenge: (args, key, callback) => { this.challengeValue = null; - cb(null); + callback(null); } - }); + }; + + this.le = LetsEncrypt.create(leConfig, leHandlers); - // TODO: Call ensureValidCertificate from an interval, to ensure - // we don't block a request each time we have to renew. + // TODO: create an interval Call to `ensureValidCertificate` so we + // don't block requests when we have to renew } - processSniCallback (cb) { - this.ensureValidCertificate() - .then((pems) => { - log.debug('SNI', this.hostname, pems.expiresAt); - cb(null, Tls.createSecureContext({ - key: pems.privkey, - cert: pems.fullchain, - ca: pems.ca - })); - }, (err) => { - log.error('SNI Fail', err); - cb(err); + getSecureContext (callback) { + this.ensureValidCertificate((err, pems) => { + if (err) { + log.error('Secure context fail', err); + return callback(err); + } + + log.debug('Secure context', this.hostname, pems.expiresAt); + + const secureContext = Tls.createSecureContext({ + key: pems.privkey, + cert: pems.fullchain, + ca: pems.ca }); - } - handleAcmeChallengeSocket (socket, path) { - socket.end('HTTP/1.0 200 OK\r\n\r\n' + this.challengeValue); + callback(null, secureContext); + }); } - fetchFromCache () { - return this._pems ? Promise.resolve(this._pems) : Promise.reject(this._pems); - } + ensureValidCertificate (callback) { + this.fetchPems((err, pems) => { + if (err) { + return this.registerCertificate(callback); + } - fetchFromDisk () { - return new Promise((resolve, reject) => { - log.info('Fetching cert from disk', this.hostname); - this.le.fetch({ - domains: [this.hostname] - }, (err, pems) => { err ? reject(err) : resolve(pems); }); + if (pems.expiresAt < Date.now() + this.RENEW_IF_EXPIRES_WITHIN_MS) { + log.warn(`Certificate expiration is stale: ${pems.expiresAt}`); + return this.registerCertificate(callback); + } + + callback(null, pems); }); } - ensureValidCertificate () { - return this.fetchFromCache() - .catch((err) => { - if (err) { - // ignored - } - return this.fetchFromDisk(); - }) - .then((pems) => { - if (pems.expiresAt < Date.now() + this.RENEW_IF_EXPIRES_WITHIN_MS) { - log.warn(`Certificate expires soon (or is already expired): ${pems.expiresAt}`); - throw new Error('expiring'); - } else { - return pems; - } - }) - .catch((err) => { + fetchPems (callback) { + if (this._pems) { + log.info('Fetching pems from cache', this.hostname); + return callback(null, this._pems); + } + + if (process.env.DANGER_SELF_SIGNED_CERT) { + log.info('Self signing cert', this.hostname); + + const config = { + days: 90, + commonName: this.hostname + }; + + Pem.createCertificate(config, (err, keys) => { if (err) { - // ignored + return callback(err); } - // register/renew - return new Promise((resolve, reject) => { - log.info('LE register', this.hostname); - this.le.register({ - domains: [this.hostname], - email: 'spacekit.io@gmail.com', - agreeTos: true - }, (err, pems) => { - log.info('LE result', !err); - if (err) { - reject(err); - } else { - resolve(pems); - } - }); - }); - }) - .then((pems) => { - log.info(`Certificate is valid! Expires ${pems.expiresAt}`); - this._pems = pems; - return pems; - }, (err) => { - log.error(`Unable to obtain valid certificate: ${err}`); - throw err; - }); - } -} -function getSelfSignedCertificate (options, cb) { - Pem.createCertificate({ - days: 90, - commonName: options.domains[0] - }, (err, keys) => { - if (err) { - cb(err); - } else { - cb(null, { - privkey: keys.serviceKey, - fullchain: keys.certificate, - ca: undefined, - expiresAt: Date.now() + 1000 * 60 * 60 * 24 * 90 + const cert = { + privkey: keys.serviceKey, + fullchain: keys.certificate, + ca: undefined, + expiresAt: Date.now() + 1000 * 60 * 60 * 24 * 90 + }; + + this._pems = cert; + + callback(null, cert); }); + + return; } - }); + + log.info('Fetching pems from disk', this.hostname); + + const options = { + domains: [this.hostname] + }; + + this.le.fetch(options, (err, pems) => { + if (err) { + log.error(`Unable to fetch certificate: ${err}`); + return callback(err); + } + + if (!pems) { + const error = Error('Certificates not found on disk.'); + return callback(error); + } + + log.info(`Certificate fetched! Expires ${pems.expiresAt}`); + + this._pems = pems; + + callback(null, pems); + }); + } + + registerCertificate (callback) { + log.info('Registering cert', this.hostname); + + const config = { + domains: [this.hostname], + email: Config.get('/letsEncrypt/email'), + agreeTos: true + }; + + this.le.register(config, (err, pems) => { + if (err) { + log.error(`Unable to register certificate: ${err}`); + return callback(err); + } + + log.info(`Certificate registered! Expires ${pems.expiresAt}`); + + this._pems = pems; + + callback(null, pems); + }); + } } module.exports = TlsCertificate; diff --git a/lib/tls-proxy-server.js b/lib/tls-proxy-server.js deleted file mode 100644 index ad65063..0000000 --- a/lib/tls-proxy-server.js +++ /dev/null @@ -1,110 +0,0 @@ -'use strict'; -const Net = require('net'); - -const CreateLogger = require('./create-logger'); - -const log = CreateLogger('CreateTlsProxyServer'); - -/** - * Create a server that extracts the hostname from incoming - * TLS (SNI) connections, puts the data back on the socket, and - * hands you back the socket and hostname. - * - * @param {function(socket, hostname)} connectionHandler - * @return {Net.Server} - */ -function createTlsProxyServer (connectionHandler) { - let server = Net.createServer((socket) => { - socket.on('error', (err) => { - log.error(err, 'proxied socket error'); - }); - - socket.once('data', (initialData) => { - socket.pause(); - socket.unshift(initialData); - - let hostname = extractHostnameFromSNIBuffer(initialData); - connectionHandler(socket, hostname); - - socket.resume(); - }); - }); - - server.on('error', (err) => { - log.error(err, 'tls server error event'); - }); - - return server; -} - -module.exports = createTlsProxyServer; - -/* -SNI parsing code -via https://github.com/axiak/filternet/blob/master/lib/sniparse.js - -Copyright (c) 2012, Michael C. Axiak -All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: - * Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above copyright - notice, this list of conditions and the following disclaimer in the - documentation and/or other materials provided with the distribution. - * Neither the name of the nor the - names of its contributors may be used to endorse or promote products - derived from this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL BE LIABLE FOR ANY -DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND -ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -*/ - -// Given a buffer for a TLS handshake packet -// the function getSNI will look for -// a server_name extension field -// and return the server_name value if found. -// See RFC 3546 (tls) and RFC 4366 (server extension) -// for more details. - -function extractHostnameFromSNIBuffer (buffer) { - if (buffer.readInt8(0) !== 22) { - // not a TLS Handshake packet - return null; - } - // Session ID Length (static position) - let currentPos = 43; - // Skip session IDs - currentPos += 1 + buffer[currentPos]; - // skip Cipher Suites - currentPos += 2 + buffer.readInt16BE(currentPos); - // skip compression methods - currentPos += 1 + buffer[currentPos]; - // We are now at extensions! - currentPos += 2; // ignore extensions length - while (currentPos < buffer.length) { - if (buffer.readInt16BE(currentPos) === 0) { - // we have found an SNI - let sniLength = buffer.readInt16BE(currentPos + 2); - currentPos += 4; - if (buffer[currentPos] !== 0) { - // the RFC says this is a reserved host type, not DNS - return null; - } - currentPos += 5; - return buffer.toString('utf8', currentPos, currentPos + sniLength - 5); - } else { - currentPos += 4 + buffer.readInt16BE(currentPos + 2); - } - } - return null; -} diff --git a/lib/web-socket-relay.js b/lib/web-socket-relay.js index b15ffa3..f70b322 100644 --- a/lib/web-socket-relay.js +++ b/lib/web-socket-relay.js @@ -31,7 +31,8 @@ class WebSocketRelay extends EventEmitter { }); }); - let currentMessageHeader = null; + let currentMessageHeader; + this.webSocket.on('message', (data) => { if (!currentMessageHeader) { currentMessageHeader = JSON.parse(data); @@ -43,7 +44,8 @@ class WebSocketRelay extends EventEmitter { } handleRelayMessage (header, data) { - let socket = this.sockets.get(header.connectionId); + const socket = this.sockets.get(header.connectionId); + if (!socket) { return; } @@ -67,30 +69,37 @@ class WebSocketRelay extends EventEmitter { } addSocket (socket, hostname, port) { - let connectionId = Uuid.v4(); + const connectionId = Uuid.v4(); this.sockets.set(connectionId, socket); - this.sendMessage({ + const openHeader = { connectionId: connectionId, type: 'open', hostname: hostname, - port: port || 443 - }, null); + port: port + }; + + this.sendMessage(openHeader, null); socket.on('data', (data) => { - this.sendMessage({ + const dataHeader = { connectionId: connectionId, type: 'data' - }, data); + }; + + this.sendMessage(dataHeader, data); }); socket.on('close', () => { this.sockets.delete(connectionId); - this.sendMessage({ + + const closeHeader = { connectionId: connectionId, type: 'close' - }, null); + }; + + this.sendMessage(closeHeader, null); }); socket.on('error', (err) => { diff --git a/lib/web/index.js b/lib/web/index.js new file mode 100644 index 0000000..1920bc4 --- /dev/null +++ b/lib/web/index.js @@ -0,0 +1,73 @@ +'use strict'; +const Hapi = require('hapi'); +const Inert = require('inert'); +const Path = require('path'); + +module.exports = function (server) { + const web = new Hapi.Server(); + + web.connection({ + listener: server + }); + + const plugins = [Inert]; + + web.register(plugins, (err) => { + /* $lab:coverage:off$ */ + if (err) { + throw err; + } + /* $lab:coverage:on$ */ + }); + + web.route({ + method: 'GET', + path: '/{param*}', + handler: { + directory: { + path: Path.resolve(__dirname, 'static'), + redirectToSlash: true, + index: true + } + } + }); + + return web; +}; + +// 'use strict'; +// const Express = require('express'); +// const Path = require('path'); +// const ServeStatic = require('serve-static'); +// const Uuid = require('node-uuid'); +// +// const CreateLogger = require('../create-logger'); +// +// const log = CreateLogger('WebApp'); +// +// module.exports = function (config) { +// const www = Express(); +// +// // If running behind a proxy, ensure we see the correct protocol for +// // redirection. See . +// www.set('trust proxy', true); +// +// // We must use "www." +// www.use((req, res, next) => { +// if (req.headers.host.slice(0, 4) !== 'www.') { +// let newHost = 'www.' + req.headers.host; +// res.redirect(301, req.protocol + '://' + newHost + req.originalUrl); +// } else { +// next(); +// } +// }); +// +// www.use((req, res, next) => { +// req.log = log.child({ reqId: Uuid.v4() }); +// next(); +// }); +// +// www.use(ServeStatic(Path.join(__dirname, 'static'))); +// +// return www; +// }; diff --git a/lib/www/static/favicon.ico b/lib/web/static/favicon.ico similarity index 100% rename from lib/www/static/favicon.ico rename to lib/web/static/favicon.ico diff --git a/lib/www/static/index.css b/lib/web/static/index.css similarity index 100% rename from lib/www/static/index.css rename to lib/web/static/index.css diff --git a/lib/www/static/index.html b/lib/web/static/index.html similarity index 100% rename from lib/www/static/index.html rename to lib/web/static/index.html diff --git a/lib/www/static/logo.png b/lib/web/static/logo.png similarity index 100% rename from lib/www/static/logo.png rename to lib/web/static/logo.png diff --git a/lib/www/static/outer-space.jpg b/lib/web/static/outer-space.jpg similarity index 100% rename from lib/www/static/outer-space.jpg rename to lib/web/static/outer-space.jpg diff --git a/lib/www/static/routing-diagram.png b/lib/web/static/routing-diagram.png similarity index 100% rename from lib/www/static/routing-diagram.png rename to lib/web/static/routing-diagram.png diff --git a/lib/www/index.js b/lib/www/index.js deleted file mode 100644 index 5cf5a0b..0000000 --- a/lib/www/index.js +++ /dev/null @@ -1,36 +0,0 @@ -'use strict'; -const Express = require('express'); -const Path = require('path'); -const ServeStatic = require('serve-static'); -const Uuid = require('node-uuid'); - -const CreateLogger = require('../create-logger'); - -const log = CreateLogger('WebApp'); - -module.exports = function (config) { - const www = Express(); - - // If running behind a proxy, ensure we see the correct protocol for - // redirection. See . - www.set('trust proxy', true); - - // We must use "www." - www.use((req, res, next) => { - if (req.headers.host.slice(0, 4) !== 'www.') { - let newHost = 'www.' + req.headers.host; - res.redirect(301, req.protocol + '://' + newHost + req.originalUrl); - } else { - next(); - } - }); - - www.use((req, res, next) => { - req.log = log.child({ reqId: Uuid.v4() }); - next(); - }); - - www.use(ServeStatic(Path.join(__dirname, 'static'))); - - return www; -}; diff --git a/migrations.sql b/migrations.sql deleted file mode 100644 index a90ddf2..0000000 --- a/migrations.sql +++ /dev/null @@ -1,21 +0,0 @@ --- Add lines to this file when you modify the database. --- Execute the appropriate statements when needed; - -CREATE TABLE IF NOT EXISTS users ( - id integer NOT NULL, - username character(255), - api_key text, - email character(255), - reset_token text, - reset_expires timestamp without time zone -); - -ALTER TABLE users ADD COLUMN created TIMESTAMP WITHOUT TIME ZONE DEFAULT NOW(); - -CREATE TABLE IF NOT EXISTS stats ( - period date, - key text, - value bigint default 0 -); - -ALTER TABLE stats ADD UNIQUE (period, key); diff --git a/package.json b/package.json index 0389ca0..d7a6ad5 100644 --- a/package.json +++ b/package.json @@ -4,9 +4,11 @@ "description": "", "main": "./lib/index.js", "scripts": { - "test": "tap tests/*.js", + "test": "lab -c -t 100 && semistandard", "lint": "semistandard", - "start": "node ./bin/index.js | bunyan" + "start": "node ./bin/index.js | bunyan", + "api-server": "nodemon ./bin/api-server.js", + "web-server": "nodemon ./bin/web-server.js" }, "bin": { "spacekit-service": "./bin/index.js" @@ -16,35 +18,41 @@ "url": "git+ssh://git@github.com/spacekit/spacekit-service.git" }, "author": "", - "license": "MPL-2.0", + "license": "MIT", "bugs": { "url": "https://github.com/spacekit/spacekit-service/issues" }, "homepage": "https://github.com/spacekit/spacekit-service#readme", "dependencies": { - "async": "1.x.x", + "async": "2.x.x", "aws-sdk": "2.x.x", "bcrypt": "0.8.x", - "body-parser": "1.x.x", + "boom": "3.x.x", "bunyan": "1.x.x", - "cors": "2.x.x", - "email-validator": "1.x.x", - "express": "4.x.x", + "confidence": "3.x.x", "handlebars": "4.x.x", + "hapi": "14.x.x", + "inert": "4.x.x", + "joi": "9.x.x", "letsencrypt": "1.x.x", "node-uuid": "1.x.x", "nodemailer": "2.x.x", "nodemailer-markdown": "1.x.x", "pem": "1.x.x", - "pg": "4.x.x", + "pg": "6.x.x", "pg-native": "1.x.x", "serve-static": "1.x.x", + "sni": "1.x.x", "ws": "1.x.x" }, "devDependencies": { + "code": "3.x.x", + "lab": "11.x.x", + "nodemon": "1.x.x", + "portfinder": "1.x.x", "pre-commit": "1.x.x", - "semistandard": "8.x.x", - "tap": "5.x.x" + "proxyquire": "1.x.x", + "semistandard": "8.x.x" }, "pre-commit": [ "lint" diff --git a/test/lib/api/auth.js b/test/lib/api/auth.js new file mode 100644 index 0000000..4837ed5 --- /dev/null +++ b/test/lib/api/auth.js @@ -0,0 +1,330 @@ +'use strict'; +const Async = require('async'); +const Code = require('code'); +const DbScaffold = require('../../../lib/db/scaffold'); +const Hapi = require('hapi'); +const Lab = require('lab'); +const Proxyquire = require('proxyquire'); +const User = require('../../../lib/db/user'); + +const lab = exports.lab = Lab.script(); +const stub = { + Mailer: {}, + Secret: {}, + User: {} +}; +const Auth = Proxyquire('../../../lib/api/auth', { + '../mailer': stub.Mailer, + '../secret': stub.Secret, + '../db/user': stub.User +}); +const server = new Hapi.Server({ debug: false }); + +lab.before((done) => { + server.connection(); + + Async.series([ + DbScaffold.setup.bind(DbScaffold), + User.create.bind(User, 'zerocool', 'zerocool@example.com'), + server.register.bind(server, [Auth]) + ], done); +}); + +lab.after((done) => { + DbScaffold.clear(done); +}); + +lab.experiment('Auth challenge', () => { + let requestRandom; + let requestStatic; + + lab.beforeEach((done) => { + requestStatic = { + method: 'POST', + url: '/auth/challenge', + payload: { + username: 'zerocool', + email: 'zerocool@example.com' + } + }; + + const rand = Math.floor(Math.random() * 10); + + requestRandom = { + method: 'POST', + url: '/auth/challenge', + payload: { + username: `zerocool${rand}`, + email: `zerocool${rand}@example.com` + } + }; + + done(); + }); + + lab.test('it successfully creates a challenge', (done) => { + const createPin = stub.Secret.createPin; + + stub.Secret.createPin = function (callback) { + stub.Secret.createPin = createPin; + stub.Secret.hash(123456, callback); + }; + + const sendEmail = stub.Mailer.sendEmail; + + stub.Mailer.sendEmail = function () { + const args = Array.prototype.slice.call(arguments); + const callback = args.pop(); + + stub.Mailer.sendEmail = sendEmail; + callback(); + }; + + server.inject(requestStatic, (response) => { + Code.expect(response.statusCode).to.equal(200); + + done(); + }); + }); + + lab.test('it goes boom when user lookup fails', (done) => { + const findOneByCredentials = stub.User.findOneByCredentials; + + stub.User.findOneByCredentials = function () { + const args = Array.prototype.slice.call(arguments); + const callback = args.pop(); + + stub.User.findOneByCredentials = findOneByCredentials; + callback(Error('sorry pal')); + }; + + server.inject(requestStatic, (response) => { + Code.expect(response.statusCode).to.equal(500); + + done(); + }); + }); + + lab.test('it goes boom when user lookup misses', (done) => { + server.inject(requestRandom, (response) => { + Code.expect(response.statusCode).to.equal(409); + + done(); + }); + }); + + lab.test('it goes boom when pin creation fails', (done) => { + const createPin = stub.Secret.createPin; + + stub.Secret.createPin = function () { + const args = Array.prototype.slice.call(arguments); + const callback = args.pop(); + + stub.Secret.createPin = createPin; + callback(Error('sorry pal')); + }; + + server.inject(requestStatic, (response) => { + Code.expect(response.statusCode).to.equal(500); + + done(); + }); + }); +}); + +lab.experiment('Auth answer', () => { + let requestRandom; + let requestStatic; + + lab.beforeEach((done) => { + requestStatic = { + method: 'POST', + url: '/auth/answer', + payload: { + username: 'zerocool', + email: 'zerocool@example.com', + pin: '123456' + } + }; + + const rand = Math.floor(Math.random() * 10); + + requestRandom = { + method: 'POST', + url: '/auth/answer', + payload: { + username: `zerocool${rand}`, + email: `zerocool${rand}@example.com`, + pin: '123456' + } + }; + + done(); + }); + + lab.test('it successfully answers a challenge', (done) => { + server.inject(requestStatic, (response) => { + Code.expect(response.statusCode).to.equal(200); + + done(); + }); + }); + + lab.test('it goes boom when user lookup fails', (done) => { + const findOneByCredentials = stub.User.findOneByCredentials; + + stub.User.findOneByCredentials = function () { + const args = Array.prototype.slice.call(arguments); + const callback = args.pop(); + + stub.User.findOneByCredentials = findOneByCredentials; + callback(Error('sorry pal')); + }; + + server.inject(requestStatic, (response) => { + Code.expect(response.statusCode).to.equal(500); + + done(); + }); + }); + + lab.test('it goes boom when user lookup misses', (done) => { + server.inject(requestRandom, (response) => { + Code.expect(response.statusCode).to.equal(409); + + done(); + }); + }); + + lab.test('it goes boom when the challenge is not set', (done) => { + server.inject(requestStatic, (response) => { + Code.expect(response.statusCode).to.equal(409); + + done(); + }); + }); + + lab.test('it goes boom when the challenge has expired', (done) => { + const findOneByCredentials = stub.User.findOneByCredentials; + + stub.User.findOneByCredentials = function (username, email, callback) { + stub.User.findOneByCredentials = findOneByCredentials; + + stub.User.findOneByCredentials(username, email, (_, user) => { + user.challenge = 'abcdefghijklmnopqrstuvwxyz'; + user.challenge_expires = '1999-01-01T00:00:00.000Z'; + + callback(null, user); + }); + }; + + server.inject(requestStatic, (response) => { + Code.expect(response.statusCode).to.equal(409); + + done(); + }); + }); + + lab.test('it goes boom when pin compare fails', (done) => { + const findOneByCredentials = stub.User.findOneByCredentials; + + stub.User.findOneByCredentials = function (username, email, callback) { + stub.User.findOneByCredentials = findOneByCredentials; + + stub.User.findOneByCredentials(username, email, (_, user) => { + user.challenge = 'abcdefghijklmnopqrstuvwxyz'; + user.challenge_expires = '2099-01-01T00:00:00.000Z'; + + callback(null, user); + }); + }; + + const compare = stub.Secret.compare; + + stub.Secret.compare = function () { + const args = Array.prototype.slice.call(arguments); + const callback = args.pop(); + + stub.Secret.compare = compare; + callback(Error('sorry pal')); + }; + + server.inject(requestStatic, (response) => { + Code.expect(response.statusCode).to.equal(500); + + done(); + }); + }); + + lab.test('it goes boom when pin compare misses', (done) => { + const findOneByCredentials = stub.User.findOneByCredentials; + + stub.User.findOneByCredentials = function (username, email, callback) { + stub.User.findOneByCredentials = findOneByCredentials; + + stub.User.findOneByCredentials(username, email, (_, user) => { + user.challenge = 'abcdefghijklmnopqrstuvwxyz'; + user.challenge_expires = '2099-01-01T00:00:00.000Z'; + + callback(null, user); + }); + }; + + const compare = stub.Secret.compare; + + stub.Secret.compare = function () { + const args = Array.prototype.slice.call(arguments); + const callback = args.pop(); + + stub.Secret.compare = compare; + callback(null, false); + }; + + server.inject(requestStatic, (response) => { + Code.expect(response.statusCode).to.equal(409); + + done(); + }); + }); + + lab.test('it goes boom when a handler routine fails', (done) => { + const findOneByCredentials = stub.User.findOneByCredentials; + + stub.User.findOneByCredentials = function (username, email, callback) { + stub.User.findOneByCredentials = findOneByCredentials; + + stub.User.findOneByCredentials(username, email, (_, user) => { + user.challenge = 'abcdefghijklmnopqrstuvwxyz'; + user.challenge_expires = '2099-01-01T00:00:00.000Z'; + + callback(null, user); + }); + }; + + const compare = stub.Secret.compare; + + stub.Secret.compare = function () { + const args = Array.prototype.slice.call(arguments); + const callback = args.pop(); + + stub.Secret.compare = compare; + callback(null, true); + }; + + const setChallenge = stub.User.setChallenge; + + stub.User.setChallenge = function () { + const args = Array.prototype.slice.call(arguments); + const callback = args.pop(); + + stub.User.setChallenge = setChallenge; + callback(Error('sorry pal')); + }; + + server.inject(requestStatic, (response) => { + Code.expect(response.statusCode).to.equal(500); + + done(); + }); + }); +}); diff --git a/test/lib/api/hello.js b/test/lib/api/hello.js new file mode 100644 index 0000000..ff24a71 --- /dev/null +++ b/test/lib/api/hello.js @@ -0,0 +1,26 @@ +'use strict'; +const Code = require('code'); +const Hapi = require('hapi'); +const Hello = require('../../../lib/api/hello'); +const Lab = require('lab'); + +const lab = exports.lab = Lab.script(); +const server = new Hapi.Server({ debug: false }); + +lab.before((done) => { + server.connection(); + server.register([Hello], done); +}); + +lab.test('it says hello', (done) => { + const request = { + method: 'GET', + url: '/' + }; + + server.inject(request, (response) => { + Code.expect(response.statusCode).to.equal(200); + + done(); + }); +}); diff --git a/test/lib/api/index.js b/test/lib/api/index.js new file mode 100644 index 0000000..ebf4f9b --- /dev/null +++ b/test/lib/api/index.js @@ -0,0 +1,24 @@ +'use strict'; +const ApiServer = require('../../../lib/api/index'); +const Code = require('code'); +const Http = require('http'); +const Lab = require('lab'); + +const lab = exports.lab = Lab.script(); + +lab.experiment('ApiServer', () => { + lab.test('it is a function', (done) => { + Code.expect(ApiServer).to.be.a.function(); + + done(); + }); + + lab.test('it creates a new hapi server', (done) => { + const server = Http.createServer(); + const apiServer = ApiServer(server); + + Code.expect(apiServer).to.be.an.object(); + + done(); + }); +}); diff --git a/test/lib/api/signup.js b/test/lib/api/signup.js new file mode 100644 index 0000000..2b3c4f8 --- /dev/null +++ b/test/lib/api/signup.js @@ -0,0 +1,160 @@ +'use strict'; +const Async = require('async'); +const Code = require('code'); +const DbScaffold = require('../../../lib/db/scaffold'); +const Hapi = require('hapi'); +const Lab = require('lab'); +const Proxyquire = require('proxyquire'); + +const lab = exports.lab = Lab.script(); +const stub = { + Secret: {}, + Session: {}, + User: {} +}; +const Signup = Proxyquire('../../../lib/api/signup', { + '../secret': stub.Secret, + '../db/session': stub.Session, + '../db/user': stub.User +}); +const server = new Hapi.Server({ debug: false }); + +lab.before((done) => { + server.connection(); + + Async.series([ + DbScaffold.setup.bind(DbScaffold), + server.register.bind(server, [Signup]) + ], done); +}); + +lab.after((done) => { + DbScaffold.clear(done); +}); + +lab.experiment('Signup', () => { + let requestRandom; + let requestStatic; + + lab.beforeEach((done) => { + requestStatic = { + method: 'POST', + url: '/signup', + payload: { + username: 'zerocool', + email: 'zerocool@example.com' + } + }; + + const rand = Math.floor(Math.random() * 10); + + requestRandom = { + method: 'POST', + url: '/signup', + payload: { + username: `zerocool${rand}`, + email: `zerocool${rand}@example.com` + } + }; + + done(); + }); + + lab.test('it registers successfully', (done) => { + server.inject(requestStatic, (response) => { + Code.expect(response.statusCode).to.equal(200); + + done(); + }); + }); + + lab.test('it goes boom when the username check fails', (done) => { + const findOneByUsername = stub.User.findOneByUsername; + + stub.User.findOneByUsername = function () { + const args = Array.prototype.slice.call(arguments); + const callback = args.pop(); + + stub.User.findOneByUsername = findOneByUsername; + callback(Error('sorry pal')); + }; + + server.inject(requestRandom, (response) => { + Code.expect(response.statusCode).to.equal(500); + + done(); + }); + }); + + lab.test('it goes boom when the username check hits', (done) => { + const findOneByUsername = stub.User.findOneByUsername; + + stub.User.findOneByUsername = function () { + const args = Array.prototype.slice.call(arguments); + const callback = args.pop(); + + stub.User.findOneByUsername = findOneByUsername; + callback(null, {}); + }; + + server.inject(requestRandom, (response) => { + Code.expect(response.statusCode).to.equal(409); + + done(); + }); + }); + + lab.test('it goes boom when the email check fails', (done) => { + const findOneByEmail = stub.User.findOneByEmail; + + stub.User.findOneByEmail = function () { + const args = Array.prototype.slice.call(arguments); + const callback = args.pop(); + + stub.User.findOneByEmail = findOneByEmail; + callback(Error('sorry pal')); + }; + + server.inject(requestRandom, (response) => { + Code.expect(response.statusCode).to.equal(500); + + done(); + }); + }); + + lab.test('it goes boom when the email check hits', (done) => { + const findOneByEmail = stub.User.findOneByEmail; + + stub.User.findOneByEmail = function () { + const args = Array.prototype.slice.call(arguments); + const callback = args.pop(); + + stub.User.findOneByEmail = findOneByEmail; + callback(null, {}); + }; + + server.inject(requestRandom, (response) => { + Code.expect(response.statusCode).to.equal(409); + + done(); + }); + }); + + lab.test('it goes boom when auth key generation fails', (done) => { + const createUuid = stub.Secret.createUuid; + + stub.Secret.createUuid = function () { + const args = Array.prototype.slice.call(arguments); + const callback = args.pop(); + + stub.Secret.createUuid = createUuid; + callback(Error('sorry pal')); + }; + + server.inject(requestRandom, (response) => { + Code.expect(response.statusCode).to.equal(500); + + done(); + }); + }); +}); diff --git a/test/lib/config.js b/test/lib/config.js new file mode 100644 index 0000000..e47bcbc --- /dev/null +++ b/test/lib/config.js @@ -0,0 +1,14 @@ +'use strict'; +const Code = require('code'); +const Lab = require('lab'); +const Config = require('../../lib/config'); + +const lab = exports.lab = Lab.script(); + +lab.experiment('Config', () => { + lab.test('it gets config data', (done) => { + Code.expect(Config.get('/')).to.be.an.object(); + + done(); + }); +}); diff --git a/test/lib/db/index.js b/test/lib/db/index.js new file mode 100644 index 0000000..d782963 --- /dev/null +++ b/test/lib/db/index.js @@ -0,0 +1,154 @@ +'use strict'; +const Code = require('code'); +const Db = require('../../../lib/db/index'); +const DbScaffold = require('../../../lib/db/scaffold'); +const Lab = require('lab'); + +const lab = exports.lab = Lab.script(); + +/* + * There were issues using `proxyquire` to stub out `pg`. We exposed `pg` as + * a property of the `Db` class so we can stub out `pg` methods manually. + */ + +lab.before((done) => { + DbScaffold.setup(done); +}); + +lab.after((done) => { + DbScaffold.clear(done); +}); + +lab.experiment('Db run commands', () => { + lab.test('it runs a command successfully', (done) => { + const command = 'SELECT $1::text AS name;'; + + Db.run(command, ['pal'], (err, results) => { + Code.expect(err).to.not.exist(); + Code.expect(results).to.exist(); + + done(); + }); + }); + + lab.test('it runs unsuccessfully when pg connect fails', (done) => { + const connect = Db.pg.connect; + + Db.pg.connect = function () { + const args = Array.prototype.slice.call(arguments); + const callback = args.pop(); + + Db.pg.connect = connect; + callback(Error('sorry pal')); + }; + + const command = 'SELECT $1::text AS name;'; + + Db.run(command, ['pal'], (err, results) => { + Code.expect(err).to.exist(); + Code.expect(results).to.not.exist(); + + done(); + }); + }); + + lab.test('it runs a series of commands successfully', (done) => { + const commands = [ + ['SELECT $1::text AS name;', ['pal']], + 'SELECT COUNT(1) AS count;' + ]; + + Db.runSeries(commands, (err, results) => { + Code.expect(err).to.not.exist(); + Code.expect(results).to.exist(); + + done(); + }); + }); + + lab.test('it runs a series unsuccessfully when pg connect fails', (done) => { + const connect = Db.pg.connect; + + Db.pg.connect = function () { + const args = Array.prototype.slice.call(arguments); + const callback = args.pop(); + + Db.pg.connect = connect; + callback(Error('sorry pal')); + }; + + const commands = [ + ['SELECT $1::text AS name;', ['pal']], + 'SELECT COUNT(1) AS count;' + ]; + + Db.runSeries(commands, (err, results) => { + Code.expect(err).to.exist(); + Code.expect(results).to.not.exist(); + + done(); + }); + }); +}); + +lab.experiment('Db run for one', () => { + lab.test('it runs for one successfully (without return row)', (done) => { + const command = ` + INSERT INTO users (username, email) + VALUES ($1, $2) + `; + const username = 'pal'; + const email = 'pal@friend'; + + Db.runForOne(command, [username, email], (err, record) => { + Code.expect(err).to.not.exist(); + Code.expect(record).to.not.exist(); + + done(); + }); + }); + + lab.test('it runs for one successfully (with return row)', (done) => { + const command = ` + INSERT INTO users (username, email) + VALUES ($1, $2) + RETURNING * + `; + const username = 'pal2'; + const email = 'pal2@friend'; + + Db.runForOne(command, [username, email], (err, record) => { + Code.expect(err).to.not.exist(); + Code.expect(record).to.exist(); + + done(); + }); + }); + + lab.test('it runs for one unsuccessfully when run fails', (done) => { + const run = Db.run; + + Db.run = function () { + const args = Array.prototype.slice.call(arguments); + const callback = args.pop(); + + Db.run = run; + callback(Error('sorry pal')); + }; + + const command = ` + INSERT INTO users (username, email) + VALUES ($1, $2) + RETURNING * + `; + const username = 'pal3'; + const email = 'pal3@friend'; + + Db.runForOne(command, [username, email], (err, record) => { + Code.expect(err).to.exist(); + Code.expect(record).to.not.exist(); + + done(); + }); + }); +}); diff --git a/test/lib/db/scaffold.js b/test/lib/db/scaffold.js new file mode 100644 index 0000000..1c29621 --- /dev/null +++ b/test/lib/db/scaffold.js @@ -0,0 +1,50 @@ +'use strict'; +const Code = require('code'); +const DbScaffold = require('../../../lib/db/scaffold'); +const Lab = require('lab'); + +const lab = exports.lab = Lab.script(); + +lab.before((done) => { + DbScaffold.setup(done); +}); + +lab.after((done) => { + DbScaffold.clear(done); +}); + +lab.experiment('Scaffold', () => { + lab.test('it runs setup successfully', (done) => { + DbScaffold.setup((err) => { + Code.expect(err).to.not.exist(); + + done(); + }); + }); + + lab.test('it runs setup unsuccessfully when tear down fails', (done) => { + const tearDown = DbScaffold.tearDown; + + DbScaffold.tearDown = function () { + const args = Array.prototype.slice.call(arguments); + const callback = args.pop(); + + DbScaffold.tearDown = tearDown; + callback(Error('sorry pal')); + }; + + DbScaffold.setup((err) => { + Code.expect(err).to.exist(); + + done(); + }); + }); + + lab.test('it runs clear successfully', (done) => { + DbScaffold.clear((err) => { + Code.expect(err).to.not.exist(); + + done(); + }); + }); +}); diff --git a/test/lib/db/session.js b/test/lib/db/session.js new file mode 100644 index 0000000..d270a2f --- /dev/null +++ b/test/lib/db/session.js @@ -0,0 +1,92 @@ +'use strict'; +const Async = require('async'); +const Code = require('code'); +const DbScaffold = require('../../../lib/db/scaffold'); +const Lab = require('lab'); +const Proxyquire = require('proxyquire'); +const Secret = require('../../../lib/secret'); +const User = require('../../../lib/db/user'); + +const lab = exports.lab = Lab.script(); +const stub = { + Db: {} +}; +const Session = Proxyquire('../../../lib/db/session', { + './index': stub.Db +}); + +lab.before((done) => { + Async.series([ + DbScaffold.setup.bind(DbScaffold), + User.create.bind(User, 'pal', 'pal@friend'), + function createSession (done) { + Secret.hash('abcdefghijklmnopqrstuvwxyz', (err, secret) => { + if (err) { + return done(err); + } + + Session.create('1', secret.hash, done); + }); + } + ], done); +}); + +lab.after((done) => { + DbScaffold.clear(done); +}); + +lab.experiment('Session', () => { + lab.test('it creates a record', (done) => { + const userId = '1'; + const authKey = 'abcdefghijklmnopqrstuvwxyz'; + + Session.create(userId, authKey, (err, record) => { + Code.expect(err).to.not.exist(); + Code.expect(record).to.exist(); + + done(); + }); + }); +}); + +lab.experiment('Session authentication', () => { + lab.test('it authenticates with a valid auth key', (done) => { + const username = 'pal'; + const authKey = 'abcdefghijklmnopqrstuvwxyz'; + + Session.authenticate(username, authKey, (err, success) => { + Code.expect(err).to.not.exist(); + Code.expect(success).to.equal(true); + + done(); + }); + }); + + lab.test('it does not authenticate with an invalid auth key', (done) => { + const username = 'pal'; + const authKey = 'zyxwvutsrqponmlkjihgfedcba'; + + Session.authenticate(username, authKey, (err, success) => { + Code.expect(err).to.not.exist(); + Code.expect(success).to.equal(false); + + done(); + }); + }); + + lab.test('it goes boom when db run fails', (done) => { + const run = stub.Db.run; + + stub.Db.run = function (plain, hash, callback) { + stub.Db.run = run; + + callback(Error('sorry pal')); + }; + + Session.authenticate(null, null, (err, success) => { + Code.expect(err).to.exist(); + + done(); + }); + }); +}); diff --git a/test/lib/db/stat.js b/test/lib/db/stat.js new file mode 100644 index 0000000..14b0518 --- /dev/null +++ b/test/lib/db/stat.js @@ -0,0 +1,29 @@ +'use strict'; +const Code = require('code'); +const DbScaffold = require('../../../lib/db/scaffold'); +const Lab = require('lab'); +const Stat = require('../../../lib/db/stat'); + +const lab = exports.lab = Lab.script(); + +lab.before((done) => { + DbScaffold.setup(done); +}); + +lab.after((done) => { + DbScaffold.clear(done); +}); + +lab.experiment('Stat', () => { + lab.test('it increments a stat', (done) => { + const key = 'bytes'; + const increment = 100; + + Stat.increment(key, increment, (err, results) => { + Code.expect(err).to.not.exist(); + Code.expect(results).to.exist(); + + done(); + }); + }); +}); diff --git a/test/lib/db/user.js b/test/lib/db/user.js new file mode 100644 index 0000000..10bfaac --- /dev/null +++ b/test/lib/db/user.js @@ -0,0 +1,78 @@ +'use strict'; +const Code = require('code'); +const DbScaffold = require('../../../lib/db/scaffold'); +const Lab = require('lab'); +const User = require('../../../lib/db/user'); + +const lab = exports.lab = Lab.script(); +let userId; + +lab.before((done) => { + DbScaffold.setup(done); +}); + +lab.after((done) => { + DbScaffold.clear(done); +}); + +lab.experiment('User', () => { + lab.test('it creates a record', (done) => { + const username = 'pal'; + const email = 'pal@friend'; + + User.create(username, email, (err, record) => { + Code.expect(err).to.not.exist(); + Code.expect(record).to.exist(); + + userId = record.id; + + done(); + }); + }); + + lab.test('it finds one by username', (done) => { + const username = 'pal'; + + User.findOneByUsername(username, (err, record) => { + Code.expect(err).to.not.exist(); + Code.expect(record).to.exist(); + + done(); + }); + }); + + lab.test('it finds one by email', (done) => { + const email = 'pal@friend'; + + User.findOneByEmail(email, (err, record) => { + Code.expect(err).to.not.exist(); + Code.expect(record).to.exist(); + + done(); + }); + }); + + lab.test('it finds one by credentials', (done) => { + const username = 'pal'; + const email = 'pal@friend'; + + User.findOneByCredentials(username, email, (err, record) => { + Code.expect(err).to.not.exist(); + Code.expect(record).to.exist(); + + done(); + }); + }); + + lab.test('it sets the challenge', (done) => { + const challenge = 'abcdefghijklmnopqrstuvwxyz'; + const expires = new Date(); + + User.setChallenge(userId, challenge, expires, (err, record) => { + Code.expect(err).to.not.exist(); + Code.expect(record).to.exist(); + + done(); + }); + }); +}); diff --git a/test/lib/dynamic-dns.js b/test/lib/dynamic-dns.js new file mode 100644 index 0000000..b74bb80 --- /dev/null +++ b/test/lib/dynamic-dns.js @@ -0,0 +1,78 @@ +'use strict'; +const Code = require('code'); +const Lab = require('lab'); +const Proxyquire = require('proxyquire'); + +const lab = exports.lab = Lab.script(); +const stub = { + AWS: { + changeResourceRecordSets: function (params, callback) { + callback(); + }, + Route53: function () { + return { + changeResourceRecordSets: function () { + stub.AWS.changeResourceRecordSets.apply(null, arguments); + } + }; + } + } +}; +const DynamicDns = Proxyquire('../../lib/dynamic-dns', { + 'aws-sdk': stub.AWS +}); + +lab.experiment('DynamicDns resolve dns', () => { + lab.test('it resolves dns for first and uses cache after', (done) => { + const hostname = 'google.com'; + + DynamicDns.resolveDns(hostname, (err, ipAddress) => { + Code.expect(err).to.not.exist(); + Code.expect(ipAddress).to.exist(); + + DynamicDns.resolveDns(hostname, (err, ipAddress) => { + Code.expect(err).to.not.exist(); + Code.expect(ipAddress).to.exist(); + + done(); + }); + }); + }); + + lab.test('it goes boom when dns resolve4 fails', (done) => { + const hostname = 'sorry.extension'; + + DynamicDns.resolveDns(hostname, (err, ipAddress) => { + Code.expect(err).to.exist(); + + done(); + }); + }); +}); + +lab.experiment('DynamicDns upsert', () => { + lab.test('it calls the aws route53 api correctly', (done) => { + const hostname = 'youwish.pal.spacekit.io'; + + DynamicDns.upsert(hostname, () => { + done(); + }); + }); + + lab.test('it goes boom when dns resolution fails', (done) => { + const hostname = 'youwish.pal.spacekit.io'; + const resolveDns = DynamicDns.resolveDns; + + DynamicDns.resolveDns = function (hostname, callback) { + DynamicDns.resolveDns = resolveDns; + + callback(Error('sorry pal')); + }; + + DynamicDns.upsert(hostname, (err) => { + Code.expect(err).to.exist(); + + done(); + }); + }); +}); diff --git a/test/lib/index.js b/test/lib/index.js new file mode 100644 index 0000000..3ceb17c --- /dev/null +++ b/test/lib/index.js @@ -0,0 +1,583 @@ +'use strict'; +const Code = require('code'); +const Config = require('../../lib/config'); +const Http = require('http'); +const Https = require('https'); +const Lab = require('lab'); +const Pem = require('pem'); +const Proxyquire = require('proxyquire'); +const WebSocket = require('ws'); + +process.env.NODE_TLS_REJECT_UNAUTHORIZED = 0; + +const lab = exports.lab = Lab.script(); +const stub = { + Session: {}, + DynamicDns: {} +}; +const SpaceKitService = Proxyquire('../../lib/index', { + './db/session': stub.Session, + './dynamic-dns': stub.DynamicDns +}); +const service = new SpaceKitService(); +const fetchPems = function (domain, callback) { + const config = { + days: 90, + commonName: domain + }; + + Pem.createCertificate(config, (err, keys) => { + if (err) { + return callback(err); + } + + const cert = { + privkey: keys.serviceKey, + fullchain: keys.certificate, + ca: undefined, + expiresAt: Date.now() + 1000 * 60 * 60 * 24 * 90 + }; + + callback(null, cert); + }); +}; +const rootCert = service.getCertificate('spacekit.io'); +const apiCert = service.getCertificate('api.spacekit.io'); +const webCert = service.getCertificate('www.spacekit.io'); +const testCert = service.getCertificate('test.spacekit.io'); + +rootCert.fetchPems = fetchPems.bind(rootCert, 'spacekit.io'); +apiCert.fetchPems = fetchPems.bind(apiCert, 'api.spacekit.io'); +webCert.fetchPems = fetchPems.bind(webCert, 'www.spacekit.io'); +testCert.fetchPems = fetchPems.bind(testCert, 'test.spacekit.io'); + +lab.experiment('SpaceKitService certificate helpers', () => { + lab.test('it gets a certificate when the cache misses and then hits', (done) => { + const cacheMiss = service.getCertificate('test.spacekit.io'); + Code.expect(cacheMiss).to.exist(); + + const cacheHit = service.getCertificate('test.spacekit.io'); + Code.expect(cacheHit).to.exist(); + + done(); + }); + + lab.test('it handles the sniCallback by producing the secure context', (done) => { + service.sniCallback('test.spacekit.io', (err, secureContext) => { + Code.expect(err).to.not.exist(); + Code.expect(secureContext).to.exist(); + + done(); + }); + }); +}); + +lab.experiment('WebSocketserver event handlers', () => { + lab.test('it sets the access control header on the headers event', (done) => { + const headerState = {}; + + const headerEventHandler = function () { + service.wss.removeListener('headers', headerEventHandler); + + Code.expect(headerState).to.have.length(1); + + done(); + }; + + service.wss.on('headers', headerEventHandler); + + Code.expect(headerState).to.have.length(0); + + service.wss.emit('headers', headerState); + }); + + lab.test('it executes the error event handler', (done) => { + const errorEventHandler = function () { + service.wss.removeListener('headers', errorEventHandler); + + done(); + }; + + service.wss.on('error', errorEventHandler); + + service.wss.emit('error', Error('sorry pal')); + }); +}); + +lab.experiment('WebSocketserver handle tls connnection', () => { + lab.test('it emits a connection event to the api server', (done) => { + const connectionHandler = function (socket) { + service.apiHttpServer.removeListener('connection', connectionHandler); + + done(); + }; + + service.apiHttpServer.on('connection', connectionHandler); + + const options = { + hostname: '127.0.0.1', + port: Config.get('/service/ports/https'), + path: '/', + method: 'GET', + headers: { + host: service.apiHostname + } + }; + + const req = Https.request(options, () => {}); + + req.on('error', (err) => { + done(err); + }); + + req.end(); + }); + + lab.test('it emits a connection event to the web server', (done) => { + const connectionHandler = function (socket) { + service.webHttpServer.removeListener('connection', connectionHandler); + + done(); + }; + + service.webHttpServer.on('connection', connectionHandler); + + const options = { + hostname: '127.0.0.1', + port: Config.get('/service/ports/https'), + path: '/', + method: 'GET', + headers: { + host: service.webHostname + } + }; + + const req = Https.request(options, () => {}); + + req.on('error', (err) => { + done(err); + }); + + req.end(); + }); + + lab.test('it emits a connection event to the web server for the root domain', (done) => { + const connectionHandler = function (socket) { + service.webHttpServer.removeListener('connection', connectionHandler); + + done(); + }; + + service.webHttpServer.on('connection', connectionHandler); + + const options = { + hostname: '127.0.0.1', + port: Config.get('/service/ports/https'), + path: '/', + method: 'GET', + headers: { + host: service.domain + } + }; + + const req = Https.request(options, () => {}); + + req.on('error', (err) => { + done(err); + }); + + req.end(); + }); + + lab.test('it closes the socket when no relays match', (done) => { + const options = { + hostname: '127.0.0.1', + port: Config.get('/service/ports/https'), + path: '/', + method: 'GET', + headers: { + host: 'miss.relay.spacekit.io' + } + }; + + const req = Https.request(options, () => {}); + + req.on('error', (err) => { + Code.expect(err).to.exist(); + + done(); + }); + + req.end(); + }); + + lab.test('it adds the socket to the matching relay', (done) => { + const mockRelay = { + addSocket: function () { + service.relays.delete('hit.relay.spacekit.io'); + + done(); + } + }; + + service.relays.set('hit.relay.spacekit.io', mockRelay); + + const options = { + hostname: '127.0.0.1', + port: Config.get('/service/ports/https'), + path: '/', + method: 'GET', + headers: { + host: 'hit.relay.spacekit.io' + } + }; + + const req = Https.request(options, () => {}); + + req.on('error', (err) => { + done(err); + }); + + req.end(); + }); +}); + +lab.experiment('WebSocketserver handle tcp connnection', () => { + lab.test('it replies with 301 for requests to the root domain', (done) => { + const options = { + hostname: '127.0.0.1', + port: Config.get('/service/ports/http'), + path: '/', + method: 'GET', + headers: { + host: service.domain + } + }; + + const req = Http.request(options, (res) => { + Code.expect(res.statusCode).to.equal(301); + Code.expect(res.headers.location).to.include(service.webHostname); + + done(); + }); + + req.on('error', (err) => { + done(err); + }); + + req.end(); + }); + + lab.test('it replies with 301 for all other non-challenge requests', (done) => { + const path = '/path/to/thing'; + const host = 'home.pal.spacekit.io'; + const options = { + hostname: '127.0.0.1', + port: Config.get('/service/ports/http'), + path: path, + method: 'GET', + headers: { + host: host + } + }; + + const req = Http.request(options, (res) => { + Code.expect(res.statusCode).to.equal(301); + Code.expect(res.headers.location).to.include(host); + Code.expect(res.headers.location).to.include(path); + + done(); + }); + + req.on('error', (err) => { + done(err); + }); + + req.end(); + }); + + lab.test('it replies with the acme challenge answer for the api subdomain', (done) => { + const path = '/.well-known/acme-challenge/abcxyz'; + const options = { + hostname: '127.0.0.1', + port: Config.get('/service/ports/http'), + path: path, + method: 'GET', + headers: { + host: service.apiHostname + } + }; + + const req = Http.request(options, (res) => { + Code.expect(res.statusCode).to.equal(200); + + let body = ''; + + res.setEncoding('utf8'); + + res.on('data', (chunk) => { + body += chunk; + }); + + res.on('end', () => { + Code.expect(body.length).to.be.above(0); + + done(); + }); + }); + + req.on('error', (err) => { + done(err); + }); + + req.end(); + }); + + lab.test('it replies with the acme challenge answer for the web subdomain', (done) => { + const path = '/.well-known/acme-challenge/abcxyz'; + const options = { + hostname: '127.0.0.1', + port: Config.get('/service/ports/http'), + path: path, + method: 'GET', + headers: { + host: service.webHostname + } + }; + + const req = Http.request(options, (res) => { + Code.expect(res.statusCode).to.equal(200); + + let body = ''; + + res.setEncoding('utf8'); + + res.on('data', (chunk) => { + body += chunk; + }); + + res.on('end', () => { + Code.expect(body.length).to.be.above(0); + + done(); + }); + }); + + req.on('error', (err) => { + done(err); + }); + + req.end(); + }); + + lab.test('it closes the socket when no relays match', (done) => { + const path = '/.well-known/acme-challenge/abcxyz'; + const options = { + hostname: '127.0.0.1', + port: Config.get('/service/ports/http'), + path: path, + method: 'GET', + headers: { + host: 'miss.relay.spacekit.io' + } + }; + + const req = Http.request(options, () => {}); + + req.on('error', (err) => { + Code.expect(err).to.exist(); + + done(); + }); + + req.end(); + }); + + lab.test('it adds the socket to the matching relay', (done) => { + const mockRelay = { + addSocket: function () { + service.relays.delete('hit.relay.spacekit.io'); + + done(); + } + }; + + service.relays.set('hit.relay.spacekit.io', mockRelay); + + const path = '/.well-known/acme-challenge/abcxyz'; + const options = { + hostname: '127.0.0.1', + port: Config.get('/service/ports/http'), + path: path, + method: 'GET', + headers: { + host: 'hit.relay.spacekit.io' + } + }; + + const req = Http.request(options, () => {}); + + req.on('error', (err) => { + done(err); + }); + + req.end(); + }); +}); + +lab.experiment('WebSocketserver relay connnection', () => { + const port = Config.get('/service/ports/https'); + const wsHost = `wss://127.0.0.1:${port}/`; + const wsProtocol = 'spacekit'; + const wsOptions = { + headers: { + 'host': service.apiHostname, + 'x-spacekit-subdomain': 'relay', + 'x-spacekit-username': 'pal', + 'x-spacekit-apikey': 'youwish' + } + }; + const relayHostname = `relay.pal.${service.domain}`; + + lab.test('it closes the websocket when authentication goes boom', (done) => { + const authenticate = stub.Session.authenticate; + + stub.Session.authenticate = function (username, authKey, callback) { + stub.Session.authenticate = authenticate; + + callback(Error('sorry pal')); + }; + + const ws = new WebSocket(wsHost, wsProtocol, wsOptions); + + ws.on('close', () => { + done(); + }); + + ws.on('error', (err) => { + done(err); + }); + }); + + lab.test('it closes the websocket when authentication fails', (done) => { + const authenticate = stub.Session.authenticate; + + stub.Session.authenticate = function (username, authKey, callback) { + stub.Session.authenticate = authenticate; + + callback(null, false); + }; + + const ws = new WebSocket(wsHost, wsProtocol, wsOptions); + + ws.on('close', () => { + done(); + }); + + ws.on('error', (err) => { + done(err); + }); + }); + + lab.test('it closes an existing websocket when another connects with the same credentials', (done) => { + const authenticate = stub.Session.authenticate; + + stub.Session.authenticate = function (username, authKey, callback) { + stub.Session.authenticate = authenticate; + + callback(null, true); + }; + + const upsert = stub.DynamicDns.upsert; + + stub.DynamicDns.upsert = function (hostname, callback) { + stub.DynamicDns.upsert = upsert; + + callback(null); + }; + + const ws1 = new WebSocket(wsHost, wsProtocol, wsOptions); + + ws1.on('close', () => { + done(); + }); + + ws1.on('error', (err) => { + done(err); + }); + + const ws2 = new WebSocket(wsHost, wsProtocol, wsOptions); + + ws2.on('error', (err) => { + done(err); + }); + }); + + lab.test('it handles the websocket error event', (done) => { + const authenticate = stub.Session.authenticate; + + stub.Session.authenticate = function (username, authKey, callback) { + stub.Session.authenticate = authenticate; + + callback(null, true); + }; + + const upsert = stub.DynamicDns.upsert; + + stub.DynamicDns.upsert = function (hostname, callback) { + stub.DynamicDns.upsert = upsert; + + callback(null); + }; + + const ws = new WebSocket(wsHost, wsProtocol, wsOptions); + + ws.on('open', () => { + const relay = service.relays.get(relayHostname); + + relay.webSocket.emit('error', Error('sorry pal')); + + done(); + }); + + ws.on('error', (err) => { + done(err); + }); + }); + + lab.test('it closes the websocket when dynamic dns goes boom', (done) => { + const authenticate = stub.Session.authenticate; + + stub.Session.authenticate = function (username, authKey, callback) { + stub.Session.authenticate = authenticate; + + callback(null, true); + }; + + const upsert = stub.DynamicDns.upsert; + + stub.DynamicDns.upsert = function (hostname, callback) { + stub.DynamicDns.upsert = upsert; + + callback(Error('sorry pal')); + }; + + const ws = new WebSocket(wsHost, wsProtocol, wsOptions); + + ws.on('close', () => { + done(); + }); + + ws.on('error', (err) => { + done(err); + }); + }); +}); + +lab.experiment('SpaceKitService close', () => { + lab.test('it closes all the servers', (done) => { + service.close(); + + done(); + }); +}); diff --git a/test/lib/mailer.js b/test/lib/mailer.js new file mode 100644 index 0000000..403c9b3 --- /dev/null +++ b/test/lib/mailer.js @@ -0,0 +1,83 @@ +'use strict'; +const Code = require('code'); +const Lab = require('lab'); +const Proxyquire = require('proxyquire'); + +const lab = exports.lab = Lab.script(); +const stub = { + fs: {}, + nodemailer: { + createTransport: function (smtp) { + return { + use: function () { + return; + }, + sendMail: function (options, callback) { + return callback(null, {}); + } + }; + } + } +}; +const Mailer = Proxyquire('../../lib/mailer', { + 'fs': stub.fs, + 'nodemailer': stub.nodemailer +}); + +lab.experiment('Mailer', () => { + lab.test('it returns error when read file fails', (done) => { + const readFile = stub.fs.readFile; + + stub.fs.readFile = function () { + const args = Array.prototype.slice.call(arguments); + const callback = args.pop(); + + stub.fs.readFile = readFile; + callback(Error('read file failed')); + }; + + Mailer.sendEmail({}, 'path', {}, (err, info) => { + Code.expect(err).to.be.an.object(); + + done(); + }); + }); + + lab.test('it sends an email', (done) => { + const readFile = stub.fs.readFile; + + stub.fs.readFile = function () { + const args = Array.prototype.slice.call(arguments); + const callback = args.pop(); + + stub.fs.readFile = readFile; + callback(null, ''); + }; + + Mailer.sendEmail({}, 'path', {}, (err, info) => { + Code.expect(err).to.not.exist(); + Code.expect(info).to.be.an.object(); + + done(); + }); + }); + + lab.test('it returns early with the template is cached', (done) => { + const readFile = stub.fs.readFile; + + stub.fs.readFile = function () { + const args = Array.prototype.slice.call(arguments); + const callback = args.pop(); + + stub.fs.readFile = readFile; + callback(null, ''); + }; + + Mailer.sendEmail({}, 'path', {}, (err, info) => { + Code.expect(err).to.not.exist(); + Code.expect(info).to.be.an.object(); + + done(); + }); + }); +}); diff --git a/test/lib/net-proxy.js b/test/lib/net-proxy.js new file mode 100644 index 0000000..1558086 --- /dev/null +++ b/test/lib/net-proxy.js @@ -0,0 +1,214 @@ +'use strict'; +const Code = require('code'); +const Lab = require('lab'); +const Net = require('net'); +const NetProxy = require('../../lib/net-proxy'); +const Portfinder = require('portfinder'); + +const lab = exports.lab = Lab.script(); + +lab.experiment('NetProxy Tls', () => { + let port; + + lab.beforeEach((done) => { + Portfinder.getPort((err, availablePort) => { + port = availablePort; + done(err); + }); + }); + + lab.test('it creates a tls proxy server', (done) => { + const tlsProxyServer = NetProxy.createTlsServer(() => {}); + + Code.expect(tlsProxyServer).to.exist(); + + done(); + }); + + lab.test('it handles a server error event', (done) => { + const tlsProxyServer = NetProxy.createTlsServer(() => {}); + + tlsProxyServer.on('error', () => { + done(); + }); + + tlsProxyServer.emit('error', Error('sorry pal')); + }); + + lab.test('it handles a socket error event', (done) => { + const connectionHandler = function (socket, hostname, path) { + socket.on('error', () => { + done(); + }); + + socket.emit('error', Error('sorry pal')); + }; + const tlsProxyServer = NetProxy.createTlsServer(connectionHandler); + + tlsProxyServer.listen(port, () => { + const socket = new Net.Socket(); + + socket.connect(port, () => { + socket.write('GET / HTTP/1.0\r\nHost: localhost\r\n\r\n'); + }); + }); + }); + + lab.test('it parses the sni hostname successfully', (done) => { + const connectionHandler = function (socket, hostname, path) { + Code.expect(hostname).to.equal('cbks1.google.com'); + + done(); + }; + const tlsProxyServer = NetProxy.createTlsServer(connectionHandler); + + tlsProxyServer.listen(port, () => { + const socket = new Net.Socket(); + + socket.connect(port, () => { + let data = '16030100b7010000b303014f2d7b3fb3847e21eb29'; + data += '7c07f8a7c4621b5ebe664790961f9e9b2dd49d6c1ab60'; + data += '00048c00ac0140088008700390038c00fc00500840035'; + data += 'c007c009c011c01300450044006600330032c00cc00ec'; + data += '002c0040096004100040005002fc008c01200160013c0'; + data += '0dc003feff000a0201000041000000150013000010636'; + data += '26b73312e676f6f676c652e636f6dff01000100000a00'; + data += '080006001700180019000b00020100002300003374000'; + data += '0000500050100000000'; + + socket.write(new Buffer(data, 'hex')); + }); + }); + }); +}); + +lab.experiment('NetProxy Tcp', () => { + let port; + + lab.beforeEach((done) => { + Portfinder.getPort((err, availablePort) => { + port = availablePort; + done(err); + }); + }); + + lab.test('it creates a tcp proxy server', (done) => { + const tcpProxyServer = NetProxy.createTcpServer(() => {}); + + Code.expect(tcpProxyServer).to.exist(); + + done(); + }); + + lab.test('it handles a server error event', (done) => { + const tcpProxyServer = NetProxy.createTcpServer(() => {}); + + tcpProxyServer.on('error', () => { + done(); + }); + + tcpProxyServer.emit('error', Error('sorry pal')); + }); + + lab.test('it handles a socket error event', (done) => { + const connectionHandler = function (socket, hostname, path) { + socket.on('error', () => { + done(); + }); + + socket.emit('error', Error('sorry pal')); + }; + const tcpProxyServer = NetProxy.createTcpServer(connectionHandler); + + tcpProxyServer.listen(port, () => { + const socket = new Net.Socket(); + + socket.connect(port, () => { + socket.write('GET / HTTP/1.0\r\nHo'); + + setTimeout(() => { + socket.write('st: localhost\r\n\r\n'); + }, 0); + }); + }); + }); + + lab.test('it ends a request when the hostname is missing', (done) => { + const tcpProxyServer = NetProxy.createTcpServer(() => {}); + + tcpProxyServer.listen(port, () => { + const socket = new Net.Socket(); + + socket.connect(port, () => { + socket.write('GET / HTTP/1.0\r\n\r\n'); + }); + + socket.on('close', (msg) => { + done(); + }); + }); + }); +}); + +lab.experiment('NetProxy host header parsing', () => { + lab.test('it parses the hostname successfully', (done) => { + const head = 'GET / HTTP/1.0\r\nHost: localhost\r\n\r\n\r\n'; + const hostname = NetProxy.parseHostFromHeader(head); + + Code.expect(hostname).to.be.equal('localhost'); + + done(); + }); + + lab.test('it returns null with an incomplete header', (done) => { + const head = 'GET / HTTP/1.0\r\nHost: local'; + const hostname = NetProxy.parseHostFromHeader(head); + + Code.expect(hostname).to.be.equal(null); + + done(); + }); + + lab.test('it returns undefined for a missing host header', (done) => { + const head = 'GET / HTTP/1.0\r\n\r\n'; + const hostname = NetProxy.parseHostFromHeader(head); + + Code.expect(hostname).to.be.equal(undefined); + + done(); + }); + + lab.test('it returns undefined for oversized request head', (done) => { + let head = 'GET / HTTP/1.0\r\n'; + for (let i = 1000; i < 1500; i++) { + head += `X-Custom-${i}: ${i}\r\n`; + } + head += '\r\n\r\n'; + + const hostname = NetProxy.parseHostFromHeader(head); + + Code.expect(hostname).to.be.equal(undefined); + + done(); + }); +}); + +lab.experiment('NetProxy path header parsing', () => { + lab.test('it parses the path successfully', (done) => { + const head = 'GET /friends HTTP/1.0\r\nHost: localhost\r\n\r\n\r\n'; + const path = NetProxy.parsePathFromHeader(head); + + Code.expect(path).to.be.equal('/friends'); + + done(); + }); + + lab.test('it returns empty string for missing path', (done) => { + const head = 'Host: localhost\r\n\r\n\r\n'; + const path = NetProxy.parsePathFromHeader(head); + + Code.expect(path).to.be.equal(''); + + done(); + }); +}); diff --git a/test/lib/secret.js b/test/lib/secret.js new file mode 100644 index 0000000..9568630 --- /dev/null +++ b/test/lib/secret.js @@ -0,0 +1,74 @@ +'use strict'; +const Code = require('code'); +const Lab = require('lab'); +const Proxyquire = require('proxyquire'); + +const lab = exports.lab = Lab.script(); +const stub = { + Bcrypt: {} +}; +const Secret = Proxyquire('../../lib/secret', { + 'bcrypt': stub.Bcrypt +}); + +lab.experiment('Secret', () => { + lab.test('it creates a uuid pair', (done) => { + Secret.createUuid((err, data) => { + Code.expect(err).to.not.exist(); + Code.expect(data.plain).to.exist(); + + done(); + }); + }); + + lab.test('it creates a pin pair', (done) => { + Secret.createPin((err, data) => { + Code.expect(err).to.not.exist(); + Code.expect(data.plain).to.exist(); + Code.expect(data.plain.length).to.equal(6); + + done(); + }); + }); + + lab.test('it hashes a value', (done) => { + Secret.hash('123456', (err, data) => { + Code.expect(err).to.not.exist(); + Code.expect(data.plain).to.equal('123456'); + + done(); + }); + }); + + lab.test('it goes boom when hashing a value fails', (done) => { + const genSalt = stub.Bcrypt.genSalt; + + stub.Bcrypt.genSalt = function () { + const args = Array.prototype.slice.call(arguments); + const callback = args.pop(); + + stub.Bcrypt.genSalt = genSalt; + callback(Error('sorry pal')); + }; + + Secret.hash('123456', (err, data) => { + Code.expect(err).to.exist(); + Code.expect(data).to.not.exist(); + + done(); + }); + }); + + lab.test('it compares sucessfully', (done) => { + const value = '654321'; + + Secret.hash(value, (_, data) => { + Secret.compare(value, data.hash, (err, pass) => { + Code.expect(err).to.not.exist(); + Code.expect(pass).to.equal(true); + + done(); + }); + }); + }); +}); diff --git a/test/lib/tls-certificate.js b/test/lib/tls-certificate.js new file mode 100644 index 0000000..73bfac5 --- /dev/null +++ b/test/lib/tls-certificate.js @@ -0,0 +1,259 @@ +'use strict'; +const Code = require('code'); +const Lab = require('lab'); +const Pem = require('pem'); +const Proxyquire = require('proxyquire'); + +const lab = exports.lab = Lab.script(); +const getSelfSignedCert = function (options, callback) { + if (getSelfSignedCert.mock) { + getSelfSignedCert.mock.apply(this, arguments); + return; + } + + const config = { + days: 90, + commonName: options.domains[0] + }; + + Pem.createCertificate(config, (err, keys) => { + if (err) { + return callback(err); + } + + const cert = { + privkey: keys.serviceKey, + fullchain: keys.certificate, + ca: undefined, + expiresAt: Date.now() + 1000 * 60 * 60 * 24 * 90 + }; + + callback(null, cert); + }); +}; +const leInstanceArgs = { + config: undefined, + handlers: undefined +}; +const stub = { + LetsEncrypt: { + create: function (config, handlers) { + leInstanceArgs.config = config; + leInstanceArgs.handlers = handlers; + + return { + fetch: getSelfSignedCert, + register: getSelfSignedCert + }; + } + } +}; +const TlsCertificate = Proxyquire('../../lib/tls-certificate', { + 'letsencrypt': stub.LetsEncrypt +}); +const domain = 'test.spacekit.io'; + +lab.experiment('TlsCertificate', () => { + lab.test('it creates a new TlsCertificate instance', (done) => { + const tlsCertificate = new TlsCertificate(domain); + + Code.expect(tlsCertificate).to.exist(); + + done(); + }); + + lab.test('it processes the lets encrypt challenge handlers', (done) => { + const tlsCertificate = new TlsCertificate(domain); + + Code.expect(tlsCertificate).to.exist(); + + leInstanceArgs.handlers.setChallenge(null, null, '123', (err) => { + Code.expect(err).to.not.exist(); + + leInstanceArgs.handlers.getChallenge(null, null, (err, value) => { + Code.expect(err).to.not.exist(); + Code.expect(value).to.equal('123'); + + leInstanceArgs.handlers.removeChallenge(null, null, (err) => { + Code.expect(err).to.not.exist(); + + done(); + }); + }); + }); + }); +}); + +lab.experiment('TlsCertificate registration', () => { + lab.test('it registers a certificate successfully', (done) => { + const tlsCertificate = new TlsCertificate(domain); + + tlsCertificate.registerCertificate((err, pems) => { + Code.expect(err).to.not.exist(); + Code.expect(pems).to.exist(); + + done(); + }); + }); + + lab.test('it goes boom when registration fails', (done) => { + getSelfSignedCert.mock = function (config, callback) { + getSelfSignedCert.mock = undefined; + + callback(Error('sorry pal')); + }; + + const tlsCertificate = new TlsCertificate(domain); + + tlsCertificate.registerCertificate((err, pems) => { + Code.expect(err).to.exist(); + + done(); + }); + }); +}); + +lab.experiment('TlsCertificate fetch pems', () => { + lab.test('it fetches pems from disk and then from cache', (done) => { + const tlsCertificate = new TlsCertificate(domain); + + tlsCertificate.fetchPems((err, pems) => { + Code.expect(err).to.not.exist(); + Code.expect(pems).to.exist(); + + tlsCertificate.fetchPems((err, pems) => { + Code.expect(err).to.not.exist(); + Code.expect(pems).to.exist(); + + done(); + }); + }); + }); + + lab.test('it goes boom when fetch returns null', (done) => { + getSelfSignedCert.mock = function (options, callback) { + getSelfSignedCert.mock = undefined; + + callback(null, null); + }; + + const tlsCertificate = new TlsCertificate(domain); + + tlsCertificate.fetchPems((err, pems) => { + Code.expect(err).to.exist(); + + done(); + }); + }); + + lab.test('it goes boom when fetch fails', (done) => { + getSelfSignedCert.mock = function (options, callback) { + getSelfSignedCert.mock = undefined; + + callback(Error('sorry pal')); + }; + + const tlsCertificate = new TlsCertificate(domain); + + tlsCertificate.fetchPems((err, pems) => { + Code.expect(err).to.exist(); + + done(); + }); + }); +}); + +lab.experiment('TlsCertificate ensure valid', () => { + lab.test('it ensures a valid certificate', (done) => { + const tlsCertificate = new TlsCertificate(domain); + + tlsCertificate.ensureValidCertificate((err, pems) => { + Code.expect(err).to.not.exist(); + Code.expect(pems).to.exist(); + + done(); + }); + }); + + lab.test('it registers when fetch fails', (done) => { + getSelfSignedCert.mock = function (options, callback) { + getSelfSignedCert.mock = undefined; + + callback(Error('sorry pal')); + }; + + const tlsCertificate = new TlsCertificate(domain); + + tlsCertificate.ensureValidCertificate((err, pems) => { + Code.expect(err).to.not.exist(); + Code.expect(pems).to.exist(); + + done(); + }); + }); + + lab.test('it registers if the renewal window is open', (done) => { + getSelfSignedCert.mock = function (options, callback) { + getSelfSignedCert.mock = undefined; + + const config = { + days: 90, + commonName: options.domains[0] + }; + + Pem.createCertificate(config, (err, keys) => { + if (err) { + return callback(err); + } + + const cert = { + privkey: keys.serviceKey, + fullchain: keys.certificate, + ca: undefined, + expiresAt: Date.now() + }; + + callback(null, cert); + }); + }; + + const tlsCertificate = new TlsCertificate(domain); + + tlsCertificate.ensureValidCertificate((err, pems) => { + Code.expect(err).to.not.exist(); + Code.expect(pems).to.exist(); + + done(); + }); + }); +}); + +lab.experiment('TlsCertificate secure context', () => { + lab.test('it returns a secure context', (done) => { + const tlsCertificate = new TlsCertificate(domain); + + tlsCertificate.getSecureContext((err, secureContext) => { + Code.expect(err).to.not.exist(); + Code.expect(secureContext).to.exist(); + + done(); + }); + }); + + lab.test('it goes boom when ensure fails', (done) => { + const tlsCertificate = new TlsCertificate(domain); + const ensureValidCertificate = tlsCertificate.ensureValidCertificate; + + tlsCertificate.ensureValidCertificate = function (callback) { + tlsCertificate.ensureValidCertificate = ensureValidCertificate; + + callback('sorry pal'); + }; + + tlsCertificate.getSecureContext((err, secureContext) => { + Code.expect(err).to.exist(); + + done(); + }); + }); +}); diff --git a/test/lib/web-socket-relay.js b/test/lib/web-socket-relay.js new file mode 100644 index 0000000..b0aa7de --- /dev/null +++ b/test/lib/web-socket-relay.js @@ -0,0 +1,195 @@ +'use strict'; +const Async = require('async'); +const Code = require('code'); +const Http = require('http'); +const Lab = require('lab'); +const Net = require('net'); +const Portfinder = require('portfinder'); +const WebSocket = require('ws'); +const WebSocketRelay = require('../../lib/web-socket-relay'); +const WebSocketServer = require('ws').Server; + +const lab = exports.lab = Lab.script(); + +lab.experiment('WebSocketRelay', () => { + let client; + let httpServer; + let port; + let relay; + let wsServer; + let tcpServer; + let currentSocket; + + lab.before((done) => { + Async.auto({ + port: function (done) { + Portfinder.getPort(done); + }, + server: ['port', function (results, done) { + port = results.port; + + tcpServer = Net.createServer((socket) => { + if (!relay) { + httpServer.emit('connection', socket); + } else { + currentSocket = socket; + relay.addSocket(socket, 'pal', port); + } + }); + + httpServer = Http.createServer((req, res) => { + res.end(); + }); + + wsServer = new WebSocketServer({ server: httpServer }); + + wsServer.on('connection', (webSocket) => { + relay = new WebSocketRelay(webSocket); + }); + + wsServer.on('error', (err) => { + console.error('WebSocketServer error', err); + }); + + tcpServer.listen(port, done); + }] + }, done); + }); + + lab.test('it creates a new relay when the client connects', (done) => { + client = new WebSocket(`ws://localhost:${port}`); + + client.on('open', () => { + Code.expect(relay).to.be.an.object(); + + done(); + }); + }); + + lab.test('it adds new sockets to the relay', (done) => { + const socket = new Net.Socket(); + + socket.connect(port, () => { + Code.expect(relay.sockets.size).to.be.greaterThan(0); + + socket.destroy(); + done(); + }); + }); + + lab.test('it relays a message', (done) => { + let currentHeader; + + const messageHandler = function (data) { + if (!currentHeader) { + currentHeader = JSON.parse(data.toString()); + } else if (currentHeader.type === 'data') { + Code.expect(data.toString()).to.be.equal('hey pal'); + + client.removeListener('message', messageHandler); + currentHeader = null; + socket.destroy(); + done(); + } else { + currentHeader = null; + } + }; + + client.on('message', messageHandler); + + const socket = new Net.Socket(); + + socket.connect(port, () => { + socket.write('hey pal'); + }); + }); + + lab.test('it handles a relayed message', (done) => { + let currentHeader; + let connectionId; + + const messageHandler = function (data) { + if (!currentHeader) { + currentHeader = JSON.parse(data.toString()); + } else if (currentHeader.type === 'data') { + if (data.toString() === 'remember me') { + connectionId = currentHeader.connectionId; + } + + currentHeader = null; + + client.removeListener('message', messageHandler); + + client.send(`{ + "connectionId":"${connectionId}", + "type": "data" + }`); + client.send('hey friend'); + client.send(`{ + "connectionId":"${connectionId}", + "type": "close" + }`); + client.send(new Buffer(0)); + } else { + currentHeader = null; + } + }; + + client.on('message', messageHandler); + + const socket = new Net.Socket(); + + socket.on('data', (data) => { + Code.expect(data.toString()).to.be.equal('hey friend'); + }); + + socket.on('close', (data) => { + socket.destroy(); + done(); + }); + + socket.connect(port, () => { + socket.write('remember me'); + }); + }); + + lab.test('it handles the error event for socket connections', (done) => { + relay.webSocket.log = { + error: function () { + relay.webSocket.log = null; + + done(); + } + }; + + const socket = new Net.Socket(); + + socket.connect(port, () => { + currentSocket.emit('error', Error('sorry pal')); + }); + }); + + lab.test('it returns early from handling a message when the socket lookup misses', (done) => { + relay.handleRelayMessage({ connectionId: 'blamo' }, null); + + done(); + }); + + lab.test('it closes sockets when the client closes', (done) => { + const socket = new Net.Socket(); + + socket.on('close', (data) => { + done(); + }); + + socket.connect(port, () => { + client.close(); + }); + }); + + lab.test('it skips sending a message if the client connection is not open', (done) => { + relay.sendMessage(null, null); + + done(); + }); +}); diff --git a/test/lib/web/index.js b/test/lib/web/index.js new file mode 100644 index 0000000..f4a403c --- /dev/null +++ b/test/lib/web/index.js @@ -0,0 +1,24 @@ +'use strict'; +const Code = require('code'); +const Http = require('http'); +const Lab = require('lab'); +const WebServer = require('../../../lib/web/index'); + +const lab = exports.lab = Lab.script(); + +lab.experiment('WebServer', () => { + lab.test('it is a function', (done) => { + Code.expect(WebServer).to.be.a.function(); + + done(); + }); + + lab.test('it creates a new hapi server', (done) => { + const server = Http.createServer(); + const webServer = WebServer(server); + + Code.expect(webServer).to.be.an.object(); + + done(); + }); +}); diff --git a/tests/test-hello.js b/tests/test-hello.js deleted file mode 100644 index f6e8e62..0000000 --- a/tests/test-hello.js +++ /dev/null @@ -1,6 +0,0 @@ -'use strict'; -const Tap = require('tap'); - -Tap.test('hello', (tap) => { - tap.end(); -});