diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..9f32c56 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,5 @@ +{ + "extends": [ + "standard" + ] +} diff --git a/.gitignore b/.gitignore index 02d8d22..6af5637 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,7 @@ results node_modules npm-debug.log -node-cd.sh \ No newline at end of file +node-cd.sh + +.idea/ +.nvmrc \ No newline at end of file diff --git a/.travis.yml b/.travis.yml index 4b3471b..a6cbd23 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,6 @@ language: node_js node_js: - - 0.6 - - 0.8 - - 0.10.30 -script: "cd src && npm install" + - 4.2.2 + - 5.0.0 +script: "npm test" +sudo: false \ No newline at end of file diff --git a/README.md b/README.md index a2ff222..63dd35b 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,27 @@ node-cd ======= -**Featherweight Github/Bitbucket Continuous Deployment** +**Featherweight Github/Bitbucket/Contentful Continuous Deployment** -Continuously deploy any code from Github to your server. +Continuously deploy any code from Github/Bitbucket/Contentful to your server. -node-cd is a simple node.js app handling Github's and Bitbucket's post-receive hooks. -It can execute any script you want on your server: deployment, testing, etc. +node-cd is a simple node.js app handling Github's, Bitbucket's and Contentful's post-receive hooks. +It can execute any script you want on your server: deployment, testing, etc. ## Installation git clone https://github.com/A21z/node-cd.git - cd node-cd/src npm install ## Usage -* `cp node-cd.template.sh node-cd.sh` -* Edit the `node-cd.sh` file to execute whatever you like after your commits (ex: stop server, git pull, start server) +* Edit the `bitbucket.sh`, `contentful.sh` or `github.sh` file to execute whatever you like after your commits (ex: stop server, git pull, start server) * **For GitHub**: Set your post-receive hook as described [here](https://help.github.com/articles/post-receive-hooks) with the url `http://yourserver.com:61440/github` * **For Bitbucket**: Set your post-receive hook as described [here](https://confluence.atlassian.com/display/BITBUCKET/POST+hook+management) with the url `http://yourserver.com:61440/bitbucket` +* **For Contentful**: Set your webhook in your Settings > Webhooks with the url `http://yourserver.com:61440/contentful` +* Add execution permission on scripts + `chmod +x bitbucket.sh contentful.sh github.sh` * Run the app - `WWW_PORT=61440 node node-cd.js` + `WWW_PORT=61440 node src/index.js` + +[![Build Status](https://travis-ci.org/A21z/node-cd.svg)](https://travis-ci.org/A21z/node-cd) diff --git a/bitbucket.sh b/bitbucket.sh new file mode 100644 index 0000000..c2bca35 --- /dev/null +++ b/bitbucket.sh @@ -0,0 +1,2 @@ +echo "Updating Template" +echo "=====================================" diff --git a/config.js b/config.js index 86cef2f..0850cc6 100644 --- a/config.js +++ b/config.js @@ -1,37 +1,40 @@ -Private = { - server: {port: "61440"}, // Port is overriden by env var "WWW_PORT" - security: { - authorizedIps:[ - '127.0.0.1', - 'localhost', - // Bitbucket IPs - // 131.103.20.165, - // 131.103.20.166 - // Github's IPs - // '207.97.227.253', - // '50.57.128.197', - // '204.232.175.75', - // '108.171.174.178' - ], - bitbucketIps: [ - '131.103.20.165', - '131.103.20.166' - ], - githubIps: [ - '207.97.227.253', - '50.57.128.197', - '204.232.175.75', - '108.171.174.178' - ], - authorizedSubnet:[ - '204.232.175.64/27', - '192.30.252.0/22' - ] - }, - repository: { - branch: 'refs/heads/master', - }, - action: {exec: "../node-cd.sh"} -}; +var Private = { + server: {port: '61440'}, // Port is overriden by env var 'WWW_PORT' + security: { + authorizedIps: [ + '127.0.0.1', + 'localhost' + ], + bitbucketIps: [ + '131.103.20.160', + '131.103.20.27', + '131.103.20.165', + '165.254.145.0', + '165.254.145.26', + '104.192.143.0', + '104.192.143.24' + ], + githubIps: [ + '207.97.227.253', + '50.57.128.197', + '204.232.175.75', + '108.171.174.178' + ], + githubAuthorizedSubnets: [ + '204.232.175.64/27', + '192.30.252.0/22' + ] + }, + repository: { + branch: 'master' + }, + action: { + exec: { + github: './github.sh', + bitbucket: './bitbucket.sh', + contentful: './contentful.sh' + } + } +} -module.exports = Private; +module.exports = Private diff --git a/contentful.sh b/contentful.sh new file mode 100644 index 0000000..a37a46a --- /dev/null +++ b/contentful.sh @@ -0,0 +1,3 @@ +echo "Updating Content" +echo "=====================================" + diff --git a/github.sh b/github.sh new file mode 100644 index 0000000..e69de29 diff --git a/node-cd.template.sh b/node-cd.template.sh deleted file mode 100755 index 25b640e..0000000 --- a/node-cd.template.sh +++ /dev/null @@ -1,23 +0,0 @@ -echo "Stop server" -echo "=====================================" -# forever stop server.js - -echo "Update Code" -echo "=====================================" -# cd ~/expro-future -# git pull -# pull if you want to merge or fetch/reset to overwrite -# git fetch -# git reset --hard origin/master - -echo "Install dependency" -# cd passpro-crm/app -# bower install - -# cd ../../passpro -# npm install - -echo "Restart server" -echo "=====================================" -# forever start server.js -# cd diff --git a/package.json b/package.json new file mode 100644 index 0000000..0db5c63 --- /dev/null +++ b/package.json @@ -0,0 +1,23 @@ +{ + "name": "node-cd", + "version": "2.0.0-rc", + "private": true, + "scripts": { + "start": "node src/index", + "test": "standard && tape test/**/*.js" + }, + "dependencies": { + "express": "4.13.3", + "body-parser": "^1.6.3", + "morgan": "^1.5.0", + "netmask": "0.0.2", + "underscore": "" + }, + "devDependencies": { + "eslint": "^1.10.1", + "eslint-config-standard": "^4.4.0", + "eslint-plugin-standard": "^1.3.1", + "standard": "^3.0.0", + "tape": "^4.2.2" + } +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..1922d9b --- /dev/null +++ b/src/index.js @@ -0,0 +1,27 @@ +var express = require('express') +var path = require('path') +var http = require('http') +var routes = require('./routes.js') +var githubController = require('./routes/github.js') +var bitbucketController = require('./routes/bitbucket.js') +var contentfulController = require('./routes/contentful.js') +var config = require('../config.js') +var app = express() +var morgan = require('morgan') +var bodyParser = require('body-parser') + +app.set('port', process.env.WWW_PORT || config.server.port) +app.use(morgan('combined')) +app.use(bodyParser.json()) +app.use(bodyParser.urlencoded({extended: true})) +app.use(express.static(path.join(__dirname + 'public'))) + +app.get('/', routes.index.index) +app.get('/favicon.ico', routes.index.favicon) +app.post('/github', githubController.create(config).post) +app.post('/bitbucket', bitbucketController.create(config).post) +app.post('/contentful', contentfulController.create(config).post) + +http.createServer(app).listen(app.get('port'), function () { + console.log('Node-cd server listening on port ' + app.get('port')) +}) diff --git a/src/node_cd.js b/src/node_cd.js deleted file mode 100644 index 80aa16b..0000000 --- a/src/node_cd.js +++ /dev/null @@ -1,33 +0,0 @@ -var express = require('express'); -var path = require('path'); -var http = require('http'); -var fs = require('fs'); -var routes = require('./routes.js'); -var config = require('../config.js'); -var app = express(); - -app.configure(function(){ - app.set('port', process.env.WWW_PORT || config.server.port); - app.use(express.logger('dev')); - app.use(express.bodyParser()); - app.use(app.router); - app.use(express.static(path.join(__dirname + 'public'))); -}); - -app.configure('development', function(){ - app.use(express.errorHandler({ dumpExceptions: true, showStack: true })); -}); - -app.configure('production', function(){ - app.use(express.errorHandler()); -}); - -//app.get('/routes', routes.__allRoutes); -app.get('/', routes.index.index); -app.get('/favicon.ico', routes.index.favicon); -app.post('/github', routes.index.github); -app.post('/bitbucket', routes.index.bitbucket); - -http.createServer(app).listen(app.get('port'), function(){ - console.log("Node-cd server listening on port " + app.get('port')); -}); diff --git a/src/package.json b/src/package.json deleted file mode 100644 index d1ce6b5..0000000 --- a/src/package.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "name": "node-cd", - "version": "1.0.0", - "private": true, - "scripts": { - "start": "node node_cd" - }, - "dependencies": { - "express": "3.0.0beta6", - "underscore": "", - "netmask": "0.0.2" - } -} diff --git a/src/routes.js b/src/routes.js index 98d4c59..5184d4d 100644 --- a/src/routes.js +++ b/src/routes.js @@ -1,22 +1,21 @@ var fs = require('fs') -var _ = require('underscore'); var controllers = [] -var files = fs.readdirSync(__dirname + "/routes") -for (var i = files.length - 1; i >= 0; i--){ - if(files[i].match(/\.js$/i)) { - controllers.push(files[i]); - } +var files = fs.readdirSync(__dirname + '/routes') +for (var i = files.length - 1; i >= 0; i--) { + if (files[i].match(/\.js$/i)) { + controllers.push(files[i]) + } } for (i = controllers.length - 1; i >= 0; i--) { - var name = controllers[i].slice(0,controllers[i].lastIndexOf('.')) - exports[name] = require("./routes/" + controllers[i]) + var name = controllers[i].slice(0, controllers[i].lastIndexOf('.')) + exports[name] = require('./routes/' + controllers[i]) } -var that = this; +var that = this -exports.__allRoutes = function(req, res){ - res.json(that); -}; \ No newline at end of file +exports.__allRoutes = function (req, res) { + res.json(that) +} diff --git a/src/routes/bitbucket.js b/src/routes/bitbucket.js new file mode 100644 index 0000000..052a512 --- /dev/null +++ b/src/routes/bitbucket.js @@ -0,0 +1,55 @@ +var config + +function Bitbucket (conf) { + config = conf +} + +function create (conf) { + return new Bitbucket(conf) +} + +module.exports.create = create + +Bitbucket.prototype.post = function (req, res) { + var authorizedIps = config.security.authorizedIps + var bitbucketIps = config.security.bitbucketIps + var commits = req.body.push.changes + var ipv4 = req.ip.replace('::ffff:', '') + + if (!(authorizedIps.indexOf(ipv4) >= 0 || bitbucketIps.indexOf(ipv4) >= 0)) { + console.log('Unauthorized IP:', req.ip) + res.writeHead(403) + res.end() + return + } + + if (commits.length <= 0) { + res.writeHead(204) + res.end() + return + } + + var commitsFromBranch = commits.filter(function (commit) { + return commit.new.name === config.repository.branch || + commit.new.name === 'refs/heads/master' || + commit.new.name === 'refs/heads/develop' + }) + + if (commitsFromBranch.length > 0) { + console.log('Executing bash file...') + myExec(config.action.exec.bitbucket) + } + + res.writeHead(200) + res.end() +} + +var myExec = function (line) { + var exec = require('child_process').exec + var execCallback = function (error) { + if (error !== null) { + console.log('exec error: ' + error) + } + } + exec(line, execCallback) +} diff --git a/src/routes/contentful.js b/src/routes/contentful.js new file mode 100644 index 0000000..5d020e4 --- /dev/null +++ b/src/routes/contentful.js @@ -0,0 +1,40 @@ +var config + +function Contentful (conf) { + config = conf +} + +function create (conf) { + return new Contentful(conf) +} + +module.exports.create = create + +Contentful.prototype.post = function (req, res) { + var headers = req.headers + + console.log('From IP Address:', req.ip) + console.log('headers', headers) + + if (!(headers && (headers['x-contentful-topic'] === 'ContentManagement.Entry.publish' || + headers['x-contentful-topic'] === 'ContentManagement.Entry.unpublish'))) { + res.writeHead(403) + res.end() + return + } + + console.log('Executing bash file...') + myExec(config.action.exec.contentful) + res.writeHead(200) + res.end() +} + +var myExec = function (line) { + var exec = require('child_process').exec + var execCallback = function (error) { + if (error !== null) { + console.log('exec error: ' + error) + } + } + exec(line, execCallback) +} diff --git a/src/routes/github.js b/src/routes/github.js new file mode 100644 index 0000000..c07bf6d --- /dev/null +++ b/src/routes/github.js @@ -0,0 +1,61 @@ +var Netmask = require('netmask').Netmask +var config + +function GitHub (conf) { + config = conf +} + +function create (conf) { + return new GitHub(conf) +} + +module.exports.create = create + +GitHub.prototype.post = function (req, res) { + var authorizedIps = config.security.authorizedIps + var githubIps = config.security.githubIps + var payload = req.body + + if (!payload) { + console.log('No payload') + res.writeHead(400) + res.end() + return + } + + var ipv4 = req.ip.replace('::ffff:', '') + if (!(inAuthorizedSubnet(ipv4) || authorizedIps.indexOf(ipv4) >= 0 || githubIps.indexOf(ipv4) >= 0)) { + console.log('Unauthorized IP:', req.ip, '(', ipv4, ')') + res.writeHead(403) + res.end() + return + } + + if (payload.ref === config.repository.branch || + payload.ref === 'refs/heads/master' || + payload.ref === 'refs/heads/develop') { + myExec(config.action.exec.github) + } + + res.writeHead(200) + res.end() +} + +var inAuthorizedSubnet = function (ip) { + var authorizedSubnet = config.security.githubAuthorizedSubnets.map(function (subnet) { + return new Netmask(subnet) + }) + return authorizedSubnet.some(function (subnet) { + return subnet.contains(ip) + }) +} + +var myExec = function (line) { + var exec = require('child_process').exec + var execCallback = function (error) { + if (error !== null) { + console.log('exec error: ' + error) + } + } + exec(line, execCallback) +} diff --git a/src/routes/index.js b/src/routes/index.js index 84f881c..a641351 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -1,79 +1,8 @@ -var config = require('../../config.js'); -var Netmask = require('netmask').Netmask - -var authorizedSubnet = config.security.authorizedSubnet.map(function(subnet){ - return new Netmask(subnet) -}) - -exports.index = function(req, res){ - res.json( { status: 'ok' }); -}; - -exports.favicon = function(req, res){ - res.writeHead(404); - res.end(); -}; - -exports.bitbucket = function(req, res){ - var authorizedIps = config.security.authorizedIps; - var bitbucketIps = config.security.bitbucketIps; - - var payload = req.body.payload; - - console.log('From IP Address:', req.ip); - console.log('payload', payload); - - if (payload && (authorizedIps.indexOf(req.ip) >= 0 || bitbucketIps.indexOf(req.ip) >= 0)){ - var commits = JSON.parse(payload).commits; - var commitsFromBranch = commits.filter(function(commit) { - return commit.branch === config.repository.branch || commit.branch === 'refs/heads/master' || commit.branch === 'refs/heads/develop'; - }); - - if (commitsFromBranch.length > 0){ - myExec(config.action.exec); - } - - res.writeHead(200); - } else { - res.writeHead(403); - } - res.end(); -}; - -exports.github = function(req, res){ - var authorizedIps = config.security.authorizedIps; - var githubIps = config.security.githubIps; - var payload = req.body.payload; - - console.log('From IP Address:', req.ip); - console.log('payload', payload); - - if (payload && (inAuthorizedSubnet(req.ip) || authorizedIps.indexOf(req.ip) >= 0 || githubIps.indexOf(req.ip) >= 0)){ - payload = JSON.parse(payload); - - if (payload.ref === config.repository.branch || payload.ref === 'refs/heads/master' || payload.ref === 'refs/heads/develop'){ - myExec(config.action.exec); - } - - res.writeHead(200); - } else { - res.writeHead(403); - } - res.end(); -}; - -var inAuthorizedSubnet = function(ip) { - return authorizedSubnet.some(function(subnet) { - return subnet.contains(ip) - }) +exports.index = function (req, res) { + res.json({status: 'ok'}) } -var myExec = function(line) { - var exec = require('child_process').exec; - var execCallback = function (error, stdout, stderr) { - if (error !== null) { - console.log('exec error: ' + error); - } - } - var child = exec(line, execCallback); +exports.favicon = function (req, res) { + res.writeHead(404) + res.end() } diff --git a/test/routes/bitbucket.test.js b/test/routes/bitbucket.test.js new file mode 100644 index 0000000..0476ce1 --- /dev/null +++ b/test/routes/bitbucket.test.js @@ -0,0 +1,81 @@ +var test = require('tape').test +var bitbucketController = require('../../src/routes/bitbucket.js') + +function mockReq () { + return { + ip: '::ffff:1.2.3.4', + body: { + push: { + changes: [{ + new: { + name: 'dummy' + } + }] + } + } + } +} + +test('The Bitbucket endpoint with authorized IP should return 200', (assert) => { + var bitbucket = bitbucketController.create({ + security: { + authorizedIps: ['1.2.3.4'], + bitbucketIps: [] + }, + repository: { + branch: 'master' + }, + action: { + exec: { + bitbucket: '../bitbucket.sh' + } + } + }) + + var req = mockReq() + var res = {} + var code + + res.writeHead = function (statusCode) { + code = statusCode + } + + res.end = function () { + assert.equal(code, 200) + assert.end() + } + + bitbucket.post(req, res) +}) + +test('The Bitbucket endpoint with unauthorized IP should return 403', (assert) => { + var bitbucket = bitbucketController.create({ + security: { + authorizedIps: [], + bitbucketIps: [] + }, + repository: { + branch: 'master' + }, + action: { + exec: { + bitbucket: '../bitbucket.sh' + } + } + }) + + var req = mockReq() + var res = {} + var code + + res.writeHead = function (statusCode) { + code = statusCode + } + + res.end = function () { + assert.equal(code, 403) + assert.end() + } + + bitbucket.post(req, res) +}) diff --git a/test/routes/contentful.test.js b/test/routes/contentful.test.js new file mode 100644 index 0000000..1c54e2a --- /dev/null +++ b/test/routes/contentful.test.js @@ -0,0 +1,53 @@ +var test = require('tape').test +var contentfulController = require('../../src/routes/contentful.js') + +test('The Contentful endpoint with correct header should return 200', (assert) => { + var contentful = contentfulController.create({ + action: { + exec: { + contentful: 'echo "test" > /dev/null' + } + } + }) + + var req = { + ip: '1.2.3.4', + headers: { + 'x-contentful-topic': 'ContentManagement.Entry.publish' + } + } + var res = {} + var code + + res.writeHead = function (statusCode) { + code = statusCode + } + + res.end = function () { + assert.equal(code, 200) + assert.end() + } + + contentful.post(req, res) +}) + +test('The Contentful endpoint with bad header should return 403', (assert) => { + var contentful = contentfulController.create({}) + + var req = { + ip: '1.2.3.4' + } + var res = {} + var code + + res.writeHead = function (statusCode) { + code = statusCode + } + + res.end = function () { + assert.equal(code, 403) + assert.end() + } + + contentful.post(req, res) +}) diff --git a/test/routes/github.test.js b/test/routes/github.test.js new file mode 100644 index 0000000..542a3c1 --- /dev/null +++ b/test/routes/github.test.js @@ -0,0 +1,126 @@ +var test = require('tape').test +var githubController = require('../../src/routes/github.js') + +test('The GitHub endpoint with authorized IP should return 200', (assert) => { + var github = githubController.create({ + security: { + authorizedIps: ['1.2.3.4'], + githubAuthorizedSubnets: [], + githubIps: [] + }, + repository: { + branch: 'master' + } + }) + + var req = { + ip: '1.2.3.4', + body: {'dummy': true} + } + var res = {} + var code + + res.writeHead = function (statusCode) { + code = statusCode + } + + res.end = function () { + assert.equal(code, 200) + assert.end() + } + + github.post(req, res) +}) + +test('The GitHub endpoint with authorized IPv6 should return 200', (assert) => { + var github = githubController.create({ + security: { + authorizedIps: ['1.2.3.4'], + githubAuthorizedSubnets: ['1.2.3.4/24'], + githubIps: [] + }, + repository: { + branch: 'master' + } + }) + + var req = { + ip: '::ffff:1.2.3.4', + body: {'dummy': true} + } + var res = {} + var code + + res.writeHead = function (statusCode) { + code = statusCode + } + + res.end = function () { + assert.equal(code, 200) + assert.end() + } + + github.post(req, res) +}) + +test('The GitHub endpoint with authorized GitHub IP should return 200', (assert) => { + var github = githubController.create({ + security: { + authorizedIps: [], + githubAuthorizedSubnets: [], + githubIps: ['1.2.3.4'] + }, + repository: { + branch: 'master' + } + }) + + var req = { + ip: '1.2.3.4', + body: {'dummy': true} + } + var res = {} + var code + + res.writeHead = function (statusCode) { + code = statusCode + } + + res.end = function () { + assert.equal(code, 200) + assert.end() + } + + github.post(req, res) +}) + +test('The GitHub endpoint with unauthorized GitHub IP should return 403', (assert) => { + var github = githubController.create({ + security: { + authorizedIps: [], + githubAuthorizedSubnets: [], + githubIps: [] + }, + repository: { + branch: 'master' + } + }) + + var req = { + ip: '1.2.3.4', + body: {'dummy': true} + } + var res = {} + var code + + res.writeHead = function (statusCode) { + code = statusCode + } + + res.end = function () { + assert.equal(code, 403) + assert.end() + } + + github.post(req, res) +})