├── .gitignore ├── Dockerfile ├── LICENSE.md ├── Makefile ├── Readme.md ├── beacon ├── .gitignore ├── Dockerfile ├── README.md ├── app.js └── package.json ├── common ├── config.js ├── email.js ├── env.js ├── index.js ├── logger.js ├── package.json ├── sign.js └── url.js ├── conf ├── defaults.json ├── development.json ├── index.js ├── package.json └── production.json ├── db ├── README.md ├── models │ ├── BeaconHistory.js │ ├── HealthcheckConfig.js │ ├── Url.js │ └── index.js ├── package.json └── setup.js ├── frontend ├── .gitignore ├── README.md ├── index.js ├── package.json ├── views │ ├── email_verify_registration.html │ ├── email_verify_unsubscribe.html │ ├── healthchecks.html │ ├── healthchecks_sites.html │ ├── home.html │ ├── layout.html │ ├── register.html │ ├── register_form.html │ └── success.html └── webpack.config.js ├── ops ├── package.json └── setup.js ├── terraform.tf └── worker ├── Dockerfile ├── README.md ├── app.js └── package.json /.gitignore: -------------------------------------------------------------------------------- 1 | keys/ 2 | .terraform 3 | terraform.tfstate* 4 | node_modules/ 5 | .DS_Store 6 | npm-debug.log 7 | *~ 8 | plan 9 | *.swp 10 | pingdummy.db 11 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mhart/alpine-node:6.2.1 2 | 3 | COPY . /src/ 4 | WORKDIR /src/frontend 5 | 6 | ENV NODE_ENV=production 7 | 8 | RUN apk update \ 9 | && apk add --upgrade openssl \ 10 | && apk add curl python make g++ ca-certificates \ 11 | && update-ca-certificates --fresh \ 12 | && npm install \ 13 | && npm rebuild node-sass \ 14 | && npm prune --production \ 15 | && apk del --purge make g++ \ 16 | && rm -rf /var/cache/apk/* /tmp/* \ 17 | && npm run build 18 | 19 | VOLUME /src 20 | EXPOSE 3000 21 | 22 | ENTRYPOINT ["node", "index.js"] 23 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Segment.io, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | DEPS= frontend/node_modules \ 3 | common/node_modules \ 4 | beacon/node_modules \ 5 | db/node_modules 6 | 7 | bucket: 8 | @test "${BUCKET}" || (echo '$$BUCKET name required' && exit 1) 9 | aws s3 mb s3://$(BUCKET) 10 | 11 | remote: 12 | @test "${BUCKET}" || (echo '$$BUCKET name required' && exit 1) 13 | @terraform remote config \ 14 | -backend=s3 \ 15 | -backend-config="bucket=$(BUCKET)" \ 16 | -backend-config="key=/terraform" 17 | 18 | .terraform: 19 | terraform get -update=true 20 | 21 | plan: 22 | terraform plan --out plan 23 | 24 | apply: 25 | terraform apply plan 26 | 27 | copy-key: 28 | @test "${IP}" || (echo 'bastion $$IP required' && exit 1) 29 | @scp -i keys/bastion-ssh \ 30 | keys/bastion-ssh \ 31 | ubuntu@${IP}:/home/ubuntu/.ssh/key.pem 32 | 33 | keys: 34 | mkdir -p keys 35 | ssh-keygen \ 36 | -C 'Generated by Stack' \ 37 | -f 'keys/bastion-ssh' 38 | 39 | docker: $(DEPS) 40 | @docker build -t frontend . 41 | @docker build -t beacon ./beacon 42 | 43 | ${DEPS}: 44 | @cd `dirname $@` && npm i 45 | 46 | clean: 47 | @rm -rf plan .terraform terraform.tfstate.backup 48 | 49 | .PHONY: remote update plan apply .terraform clean 50 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | 2 | # pingdummy 3 | 4 | A dummy healthcheck app deployed via the Segment [Stack][stack]. 5 | 6 | [stack]: https://github.com/segmentio/stack 7 | 8 | ## Bootstrap the app 9 | 10 | First you can set up the initial DB tables correctly by using: 11 | 12 | $ node db/setup.js 13 | 14 | Next you will need to set up an SES identity so the app can send out emails: 15 | 16 | $ node ops/setup.js 17 | 18 | ## Terraform setup 19 | 20 | If you don't have ssh keys in AWS, you can create them using: 21 | 22 | $ make keys 23 | 24 | Next you'll want to set up an S3 bucket as a way to manage the terraform state remotely. 25 | 26 | $ make bucket BUCKET= 27 | 28 | Anyone who is making changes to terraform will then want to configure terraform to pull from the remote state. 29 | 30 | $ make remote BUCKET= 31 | 32 | After that, terraform is configured and ready to run against the remote state. Assuming you have your AWS credentials exported, you can simply run 33 | 34 | $ make plan # see changes 35 | $ make apply # apply the changes 36 | 37 | If you created keys using `make keys`, you will want to copy them to bastion 38 | in order to be able to ssh to other machines, First grab the bastion host public ip using `terraform output`: 39 | 40 | bastion_ip = x.x.x.x 41 | 42 | Next copy the keys to the bastion: 43 | 44 | $ make copy-key IP=x.x.x.x 45 | -------------------------------------------------------------------------------- /beacon/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /beacon/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mhart/alpine-node:6.2.1 2 | 3 | COPY . /src/ 4 | 5 | WORKDIR /src/ 6 | ENV NODE_ENV=production 7 | 8 | RUN apk update \ 9 | && apk add --upgrade openssl \ 10 | && apk add curl python make g++ ca-certificates \ 11 | && update-ca-certificates --fresh \ 12 | && npm rebuild \ 13 | && npm prune --production \ 14 | && apk del --purge make g++ \ 15 | && rm -rf /var/cache/apk/* /tmp/* 16 | 17 | VOLUME /src 18 | 19 | ENTRYPOINT ["node", "app.js"] 20 | -------------------------------------------------------------------------------- /beacon/README.md: -------------------------------------------------------------------------------- 1 | = Pingdummy Beacon Service 2 | 3 | == Usage 4 | 5 | $ npm start 6 | -------------------------------------------------------------------------------- /beacon/app.js: -------------------------------------------------------------------------------- 1 | var koa = require('koa'); 2 | var router = require('koa-router')(); 3 | var request = require('co-request'); 4 | var common = require('pingdummy-common'); 5 | 6 | var logger = common.logger; 7 | var config = common.config; 8 | var httpUserAgent = 'Pingdummy Beacon 0.0.1'; 9 | 10 | // instantiate the base koa instance 11 | var app = module.exports = koa(); 12 | app.use(require('koa-logger')()); 13 | 14 | // route table 15 | router 16 | .get('/ping/:url', pingGet) 17 | .get('/', healthcheck); 18 | 19 | app.use(router.routes()); 20 | 21 | logger.log('info', 'Attaching SIGINT listener.'); 22 | process.on('SIGINT', function() { 23 | process.exit(); 24 | }); 25 | 26 | function *healthcheck(next){ 27 | this.status = 200; 28 | } 29 | 30 | function *pingGet(next) { 31 | logger.log('debug', 'pingGet', { url: this.params.url }); 32 | 33 | var result; 34 | 35 | try { 36 | var result = yield request({ 37 | url: this.params.url, 38 | timeout: config.get("worker:timeout_millis"), 39 | headers: { 'User-Agent': httpUserAgent } 40 | }); 41 | } 42 | catch (err) { 43 | logger.log('debug', 'performCheck soft error', { 44 | url: this.params.url, 45 | err: err.toString() 46 | }); 47 | return 0; 48 | } 49 | 50 | logger.log('debug', 'pingGet response', { 51 | url: this.params.url, 52 | responseCode: result.statusCode 53 | }); 54 | 55 | this.body = JSON.stringify({"statusCode": result.statusCode}); 56 | } 57 | 58 | if (!module.parent) { 59 | logger.log('info', 'App starting up...'); 60 | 61 | app.listen(3001); 62 | 63 | logger.log('info', 'App now listening on port.'); 64 | } -------------------------------------------------------------------------------- /beacon/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pingdummy-beacon", 3 | "version": "0.0.1", 4 | "description": "Pingdummy Beacon Service", 5 | "scripts": { 6 | "test": "echo \"Error: no test specified\" && exit 1", 7 | "start": "nodemon --watch app.js app.js" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/segmentio/stack.git" 12 | }, 13 | "author": "Segment Engineering Team ", 14 | "license": "MIT", 15 | "bugs": { 16 | "url": "https://github.com/segmentio/stack/issues" 17 | }, 18 | "homepage": "https://github.com/segmentio/stack#readme", 19 | "dependencies": { 20 | "co-request": "^1.0.0", 21 | "koa": "^1.2.0", 22 | "koa-logger": "^1.3.0", 23 | "koa-router": "^5.4.0", 24 | "node-datetime": "^1.0.0", 25 | "nodemon": "^1.9.2", 26 | "pingdummy-common": "file:../common", 27 | "pingdummy-db": "file:../db", 28 | "winston": "^2.2.0" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /common/config.js: -------------------------------------------------------------------------------- 1 | var conf = require('pingdummy-conf'); 2 | var path = require('path'); 3 | var env = require('./env'); 4 | 5 | var provider = conf(env.current); 6 | 7 | module.exports = { 8 | get: provider.get.bind(provider), 9 | required: provider.required.bind(provider) 10 | } 11 | -------------------------------------------------------------------------------- /common/email.js: -------------------------------------------------------------------------------- 1 | var config = require('./config'); 2 | var logger = require('./logger'); 3 | var AWS = require('aws-sdk'); 4 | 5 | config.required([ 6 | 'notification_from_email', 7 | 'send_emails' 8 | ]); 9 | 10 | function *sendEmail(from, to, subject, message) { 11 | var ses = new AWS.SES({apiVersion: '2010-12-01'}); 12 | var params = { 13 | Destination: { 14 | ToAddresses: [ 15 | to 16 | ] 17 | }, 18 | Message: { 19 | Subject: { 20 | Data: subject, 21 | Charset: 'UTF-8' 22 | }, 23 | Body: { 24 | Text: { 25 | Data: message, 26 | Charset: 'UTF-8' 27 | } 28 | } 29 | }, 30 | Source: from, 31 | }; 32 | 33 | logger.log('debug', 'sending email', { 34 | to: to, 35 | from: from, 36 | subject: subject, 37 | bodyLength: message.length 38 | }); 39 | 40 | if (config.get('send_emails')) { 41 | var res = yield function(cb) { 42 | ses.sendEmail(params, cb); 43 | }; 44 | } 45 | else { 46 | logger.log('warn', 'no email actually sent (disabled in config)', { body: JSON.stringify(message.toString()) }); 47 | } 48 | 49 | return res; 50 | } 51 | 52 | function *send(to, subject, message) { 53 | return yield sendEmail( 54 | config.get('notification_from_email'), 55 | to, 56 | subject, 57 | message); 58 | } 59 | 60 | module.exports = { 61 | send: send 62 | }; 63 | -------------------------------------------------------------------------------- /common/env.js: -------------------------------------------------------------------------------- 1 | var current = process.env.NODE_ENV || 'development'; 2 | 3 | module.exports = { 4 | current: current, 5 | } 6 | -------------------------------------------------------------------------------- /common/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: require('./env'), 3 | config: require('./config'), 4 | logger: require('./logger'), 5 | url: require('./url'), 6 | email: require('./email'), 7 | sign: require('./sign') 8 | } 9 | -------------------------------------------------------------------------------- /common/logger.js: -------------------------------------------------------------------------------- 1 | var winston = require('winston'); 2 | var config = require('./config'); 3 | var env = require('./env'); 4 | 5 | config.required(['log_level']); 6 | 7 | var logger = new (winston.Logger)({ 8 | level: config.get('log_level'), 9 | transports: [ 10 | new (winston.transports.Console)() 11 | ] 12 | }) 13 | 14 | logger.log('info', 'logger started', { 15 | level: logger.level, 16 | environment: env.current}); 17 | 18 | module.exports = logger; 19 | -------------------------------------------------------------------------------- /common/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pingdummy-common", 3 | "version": "0.0.1", 4 | "description": "Pingdummy Common Library", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/segmentio/pingdummy.git" 12 | }, 13 | "author": "Segment Engineering Team ", 14 | "license": "MIT", 15 | "bugs": { 16 | "url": "https://github.com/segmentio/pingdummy/issues" 17 | }, 18 | "homepage": "https://github.com/segmentio/pingdummy#readme", 19 | "dependencies": { 20 | "aws-sdk": "^2.3.19", 21 | "nconf": "^0.8.4", 22 | "winston": "^2.2.0", 23 | "pingdummy-conf": "file:../conf" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /common/sign.js: -------------------------------------------------------------------------------- 1 | var crypto = require('crypto'); 2 | var config = require('./config'); 3 | 4 | config.required(['signing_secret']); 5 | 6 | var secret = config.get('signing_secret'); 7 | 8 | function newHmac(extra) { 9 | return crypto.createHmac('sha256', config.get('signing_secret') + extra); 10 | } 11 | 12 | function leftPad(str, length, padChar) { 13 | var amountToPad = length - str.length; 14 | var pad = ''; 15 | for (var i = 0; i < amountToPad; i++) { 16 | pad += padChar; 17 | } 18 | return pad + str; 19 | } 20 | 21 | // signs content with the global secret, returns the signature 22 | function sign(content) { 23 | var hmac = newHmac(''); 24 | hmac.update(content); 25 | return hmac.digest('hex'); 26 | } 27 | 28 | // signs content with an expiring signature 29 | function signExpiring(content, expiresAt) { 30 | var timestamp = expiresAt.getTime(); 31 | var encodedTimestamp = leftPad(timestamp.toString(16), 16, '0'); 32 | var hmac = newHmac('!' + encodedTimestamp); 33 | hmac.update(content); 34 | return hmac.digest('hex') + encodedTimestamp; 35 | } 36 | 37 | // verifies a signature created with signExpiring() 38 | function verifyExpiring(content, encodedSignature) { 39 | if (encodedSignature.length != 80) { 40 | return false; 41 | } 42 | var sig = encodedSignature.slice(0, 63); 43 | var ts = encodedSignature.slice(64); 44 | var expiresAt = new Date(parseInt(ts, 16)); 45 | var expectedSignature = signExpiring(content, expiresAt); 46 | 47 | // omg timing attacks 48 | if (encodedSignature == expectedSignature) { 49 | var expiresAtTimestamp = expiresAt.getTime(); 50 | var nowTimestamp = new Date().getTime(); 51 | return expiresAtTimestamp > nowTimestamp; 52 | } 53 | 54 | return false; 55 | } 56 | 57 | module.exports = { 58 | sign: sign, 59 | signExpiring: signExpiring, 60 | verifyExpiring: verifyExpiring 61 | }; 62 | -------------------------------------------------------------------------------- /common/url.js: -------------------------------------------------------------------------------- 1 | var url = require('url'); 2 | var sign = require('./sign'); 3 | 4 | // takes a URL string and returns a pingdummy-normalized version as a string 5 | function normalize(inputUrl) { 6 | // url.parse will lowercase the protocol & hostname by itself 7 | var urlObject = url.parse(inputUrl); 8 | 9 | delete urlObject.auth; 10 | delete urlObject.port; 11 | delete urlObject.host; // in order for port deletion to compose properly, 12 | // have to delete host too 13 | delete urlObject.hash; 14 | delete urlObject.query; 15 | delete urlObject.search; 16 | 17 | return url.format(urlObject); 18 | } 19 | 20 | // returns a signed version of a URL. doesn't take into consideration 21 | // the origin (protocol, hostname, port, etc). 22 | function signed(urlToSign, expiresAt) { 23 | var urlObject = url.parse(urlToSign, true); 24 | var signature = sign.signExpiring(urlObject.path, expiresAt); 25 | urlObject.query['__code'] = signature; 26 | delete urlObject.search; 27 | return url.format(urlObject); 28 | } 29 | 30 | // returns true if the signature in signed()-generated URL is valid 31 | function verifySignature(requestUrl) { 32 | var urlObject = url.parse(requestUrl, true); 33 | var sig = urlObject.query['__code']; 34 | delete urlObject.query['__code']; 35 | delete urlObject.search; 36 | urlObject = url.parse(url.format(urlObject)); 37 | return sign.verifyExpiring(urlObject.path, sig); 38 | } 39 | 40 | module.exports = { 41 | normalize: normalize, 42 | signed: signed, 43 | verifySignature: verifySignature 44 | }; 45 | -------------------------------------------------------------------------------- /conf/defaults.json: -------------------------------------------------------------------------------- 1 | { 2 | "database": { 3 | "uri": "sqlite://", 4 | "options": { 5 | "storage": "pingdummy.db" 6 | } 7 | }, 8 | "frontend": { 9 | "dynamic_assets": true 10 | }, 11 | "beacon": { 12 | "timeout_millis": 5000, 13 | "uri": "http://beacon:3001" 14 | }, 15 | "worker": { 16 | "max_interval_seconds": 60, 17 | "timeout_millis": 2000, 18 | "parallelism": 512 19 | }, 20 | "links_expire_in_millis": 864000000, 21 | "send_emails": true, 22 | "log_level": "debug" 23 | } 24 | -------------------------------------------------------------------------------- /conf/development.json: -------------------------------------------------------------------------------- 1 | { 2 | "signing_secret": "DEVELOPMENT!MODE", 3 | "notification_from_email": "dev@pingdummy.com", 4 | "site_url": "http://localhost:3000", 5 | "send_emails": false 6 | } 7 | -------------------------------------------------------------------------------- /conf/index.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | var nconf = require('nconf'); 3 | 4 | /** 5 | * Returns the config for the environment, `env` 6 | * 7 | * @param {String} env the environment string 8 | */ 9 | 10 | module.exports = function(env){ 11 | var provider = new nconf.Provider(); 12 | // overrides passed in via arguments and env vars take precedent 13 | provider.argv(); 14 | provider.env(); 15 | 16 | var defaultsConfigPath = path.join(__dirname, 'defaults.json'); 17 | var envConfigPath = path.join(__dirname, env + '.json'); 18 | 19 | [envConfigPath, defaultsConfigPath].forEach(function(path) { 20 | provider.add(path, { type: 'file', file: path }); 21 | }); 22 | 23 | return provider; 24 | } -------------------------------------------------------------------------------- /conf/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pingdummy-conf", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "author": "", 10 | "license": "MIT", 11 | "dependencies": { 12 | "nconf": "^0.8.4" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /conf/production.json: -------------------------------------------------------------------------------- 1 | { 2 | "database": { 3 | "uri": "mysql://root:password@pingdummy.cluster-cn9v6bxusqhm.us-west-2.rds.amazonaws.com:3306/pingdummy", 4 | "options": { 5 | "pool": { 6 | "max": 5, 7 | "min": 0, 8 | "idle": 10000 9 | } 10 | } 11 | }, 12 | "frontend": { 13 | "dynamic_assets": false 14 | }, 15 | "beacon": { 16 | "uri": "http://beacon.stack.local:80" 17 | }, 18 | "log_level": "info", 19 | "notification_from_email": "info@pingdummy.com", 20 | "signing_secret": "pingdummy_app", 21 | "site_url": "http://pingdummy.com/" 22 | } 23 | -------------------------------------------------------------------------------- /db/README.md: -------------------------------------------------------------------------------- 1 | # Pingdummy DB scripts 2 | 3 | Scripts for setting up the DB appropriately for the web app. Run `node setup.js` to create the initial DB tables. -------------------------------------------------------------------------------- /db/models/BeaconHistory.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | module.exports = function(sequelize, DataTypes) { 4 | return sequelize.define('BeaconHistory', { 5 | url: { 6 | type: DataTypes.STRING, 7 | field: 'url', 8 | allowNull: false, 9 | validate: { 10 | isUrl: true, 11 | notEmpty: true 12 | } 13 | }, 14 | pingTime: { 15 | type: DataTypes.DATE, 16 | field: 'ping_time' 17 | }, 18 | statusCode: { 19 | type: DataTypes.INTEGER, 20 | field: 'status_code', 21 | validate: { 22 | isInt: true 23 | } 24 | } 25 | }, { 26 | tableName: 'beacon_history', 27 | indexes: [ 28 | { 29 | fields: ['url', 'ping_time'] 30 | }, 31 | ] 32 | }) 33 | }; -------------------------------------------------------------------------------- /db/models/HealthcheckConfig.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | module.exports = function(sequelize, DataTypes) { 4 | return sequelize.define('HealthcheckConfig', { 5 | ownerEmail: { 6 | type: DataTypes.STRING, 7 | field: 'owner_email', 8 | allowNull: false, 9 | validate: { 10 | isEmail: true, 11 | notEmpty: true 12 | } 13 | }, 14 | url: { 15 | type: DataTypes.STRING, 16 | field: 'url', 17 | allowNull: false, 18 | validate: { 19 | isUrl: true, 20 | notEmpty: true 21 | } 22 | }, 23 | emailValidated: { 24 | type: DataTypes.BOOLEAN, 25 | field: 'email_validated', 26 | } 27 | }, { 28 | tableName: 'healthcheck_configs', 29 | indexes: [ 30 | { 31 | fields: ['owner_email', 'url'] 32 | }, 33 | ] 34 | }) 35 | } -------------------------------------------------------------------------------- /db/models/Url.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | module.exports = function(sequelize, DataTypes) { 4 | return sequelize.define('Url', { 5 | url: { 6 | type: DataTypes.STRING, 7 | field: 'url', 8 | primaryKey: true, 9 | allowNull: false, 10 | validate: { 11 | isUrl: true, 12 | notEmpty: true 13 | } 14 | }, 15 | lastViewTime: { 16 | type: DataTypes.DATE, 17 | field: 'last_view_time' 18 | } 19 | }, { 20 | tableName: 'urls' 21 | }) 22 | }; -------------------------------------------------------------------------------- /db/models/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var fs = require("fs"); 4 | var path = require("path"); 5 | var Sequelize = require("sequelize"); 6 | var common = require('pingdummy-common'); 7 | var logger = common.logger; 8 | 9 | // bootstrap database from config 10 | common.config.required([ 11 | 'database:uri', 12 | 'database:options' 13 | ]); 14 | var dbConfig = common.config.get('database'); 15 | 16 | // transform any storage path to be relative to the pingdummy root dir 17 | var dbConfigOptions = dbConfig.options; 18 | if ('storage' in dbConfigOptions) { 19 | dbConfigOptions.storage = path.join( 20 | __dirname, 21 | '..', 22 | '..', 23 | dbConfigOptions.storage); 24 | } 25 | 26 | dbConfigOptions.logging = function(str) { 27 | logger.log('debug', '[Sequelize] ' + str); 28 | } 29 | 30 | var sequelize = new Sequelize(dbConfig.uri, dbConfigOptions); 31 | var db = {}; 32 | 33 | fs 34 | .readdirSync(__dirname) 35 | .filter(function(file) { 36 | return (file.indexOf(".") !== 0) && (file !== "index.js"); 37 | }) 38 | .forEach(function(file) { 39 | var model = sequelize.import(path.join(__dirname, file)); 40 | db[model.name] = model; 41 | }); 42 | 43 | Object.keys(db).forEach(function(modelName) { 44 | if ("associate" in db[modelName]) { 45 | db[modelName].associate(db); 46 | } 47 | }); 48 | 49 | db.sequelize = sequelize; 50 | db.Sequelize = Sequelize; 51 | 52 | module.exports = db; 53 | -------------------------------------------------------------------------------- /db/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pingdummy-db", 3 | "version": "0.0.1", 4 | "description": "Pingdummy DB setup scripts", 5 | "main": "setup.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "setup": "node setup.js" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/segmentio/stack.git" 13 | }, 14 | "author": "Segment Engineering Team ", 15 | "license": "MIT", 16 | "bugs": { 17 | "url": "https://github.com/segmentio/stack/issues" 18 | }, 19 | "homepage": "https://github.com/segmentio/stack#readme", 20 | "dependencies": { 21 | "mysql": "^2.11.1", 22 | "pingdummy-common": "file:../common", 23 | "sequelize": "^3.23.3", 24 | "sqlite3": "^3.1.4" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /db/setup.js: -------------------------------------------------------------------------------- 1 | var models = require("./models"); 2 | 3 | models.sequelize.sync(); -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | public 3 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | = Koa-based frontend 2 | 3 | == Usage 4 | 5 | $ npm start 6 | 7 | -------------------------------------------------------------------------------- /frontend/index.js: -------------------------------------------------------------------------------- 1 | var common = require('pingdummy-common'); 2 | var koa = require('koa'); 3 | var models = require('pingdummy-db/models'); 4 | var parse = require('co-body'); 5 | var path = require('path'); 6 | var querystring = require('querystring'); 7 | var url = require('url'); 8 | var views = require('co-views'); 9 | 10 | var logger = common.logger; 11 | 12 | // instantiate the base koa instance 13 | var app = module.exports = koa(); 14 | 15 | app.use(require('koa-logger')()); 16 | app.use(require('koa-session')(app)); 17 | app.use(require('koa-flash')()); 18 | 19 | var router = require('koa-router')(); 20 | 21 | // setup the common render path 22 | var frontendConfig = common.config.get('frontend'); 23 | var _render = views(__dirname + '/views', { 24 | locals: { 25 | config: frontendConfig, 26 | }, 27 | map: { 28 | html: 'swig' 29 | } 30 | }); 31 | 32 | // optionally dynamically generate assets using middleware (useful for dev) 33 | if (frontendConfig.dynamic_assets) { 34 | var webpackMiddleware = require('koa-webpack-dev-middleware'); 35 | var webpack = require('webpack'); 36 | app.use(webpackMiddleware( 37 | webpack(require('./webpack.config')), { 38 | noInfo: false, 39 | quiet: false, 40 | lazy: true, 41 | watchOptions: { 42 | aggregateTimeout: 300, 43 | poll: true 44 | }, 45 | publicPath: "/assets/", 46 | stats: { 47 | color: true 48 | } 49 | }) 50 | ); 51 | } 52 | else { 53 | var mount = require('koa-mount'); 54 | var assetsApp = koa(); 55 | assetsApp.use(require('koa-static')(path.join(__dirname, 'public/assets'))); 56 | app.use(mount('/assets', assetsApp)); 57 | } 58 | 59 | // route table 60 | router.get('home', '/', home); 61 | router.get('/register', getRegister); 62 | router.post('/register', postRegister); 63 | router.get('verify', '/verify', verifyRegistration); 64 | router.get('/healthcheck', getHealthCheck); 65 | router.get('showHealthcheck', '/healthcheck/:url', getHealthCheckForUrl); 66 | router.get('/unsubscribe', getUnsubscribe); 67 | 68 | app.use(router.routes()); 69 | app.keys = [common.config.get('signing_secret')]; 70 | 71 | function *render(ctx, path, opts) { 72 | opts = opts || {}; 73 | opts.locals = opts.locals || {}; 74 | if (ctx && ctx.flash) { 75 | opts.locals['flash'] = ctx.flash; 76 | } 77 | return yield _render(path, opts); 78 | } 79 | 80 | function *home(next) { 81 | this.body = yield render(this, 'home'); 82 | } 83 | 84 | function *getRegister(next) { 85 | this.body = yield render(this, 'register'); 86 | } 87 | 88 | function signedRouteUrl(routeKey, query) { 89 | var routePath = router.url(routeKey); 90 | var baseUrl = url.resolve(common.config.get("site_url"), routePath); 91 | var urlObject = url.parse(baseUrl, true); 92 | delete urlObject.search; 93 | urlObject.query = query; 94 | var materializedUrl = url.format(urlObject); 95 | var expiresAt = new Date(new Date().getTime() + common.config.get('links_expire_in_millis')); 96 | return common.url.signed(materializedUrl, expiresAt); 97 | } 98 | 99 | function *postRegister(next) { 100 | var post = yield parse(this); 101 | var postEmail = post.email; 102 | var postUrl = common.url.normalize(post.url); 103 | var verifyLink = signedRouteUrl('verify', { url: postUrl, email: postEmail }); 104 | 105 | var emailSent = yield common.email.send( 106 | postEmail, 107 | '[pingdummy] Verify Registration', 108 | yield render(null, 'email_verify_registration', { verifyLink: verifyLink })); 109 | 110 | this.body = yield render(this, 'success', { email: postEmail }); 111 | } 112 | 113 | function *verifyRegistration(next) { 114 | if (common.url.verifySignature(this.request.href)) { 115 | var created = yield models.HealthcheckConfig.create({ 116 | ownerEmail: this.query.email, 117 | url: this.query.url, 118 | emailValidated: true 119 | }); 120 | 121 | if (created) { 122 | var [urlModel, created] = yield models.Url.findCreateFind({ 123 | where: { url: this.query.url }, 124 | defaults: { url: this.query.url }, 125 | }); 126 | 127 | logger.log('debug', 'upserted URL', { created: created }); 128 | this.redirect(router.url('showHealthcheck', { url: this.query.url })); 129 | return; 130 | } 131 | } 132 | else { 133 | logger.log('info', 'verifyRegistration: bad signature', { params: this.params }); 134 | } 135 | 136 | this.redirect(router.url('home')); 137 | } 138 | 139 | function *getUnsubscribe(next) { 140 | this.flash = { type: 'warning', message: 'Failed to unsubscribe you :(' }; 141 | 142 | if (common.url.verifySignature(this.request.href)) { 143 | var deleted = yield models.HealthcheckConfig.destroy({ 144 | where: { 145 | ownerEmail: this.query.email, 146 | url: this.query.url 147 | } 148 | }); 149 | 150 | this.flash = { type: 'success', 151 | message: 'Successfully unsubscribed ' + this.query.email }; 152 | } 153 | else { 154 | logger.log('info', 'unsubscribe failed: bad signature', { params: this.params }); 155 | } 156 | 157 | this.redirect(router.url('home')); 158 | } 159 | 160 | function *getHealthCheck(next) { 161 | this.body = yield render(this, 'healthchecks'); 162 | } 163 | 164 | function *getHealthCheckForUrl(next) { 165 | // Get last 100 health checks for specified URL. 166 | var normalizedUrl = common.url.normalize(this.params.url); 167 | beaconHistory = yield models.BeaconHistory.findAll({ 168 | where: { 169 | url: normalizedUrl 170 | }, 171 | limit: 100, 172 | order: [ 173 | ['ping_time', 'DESC'], 174 | ] 175 | }); 176 | 177 | // Convert dates to a more human readable format. 178 | var dateFormat = require('dateformat'); 179 | var beaconHistoryReadable = []; 180 | for (var i = 0; i < beaconHistory.length; i++) { 181 | var entry = { 182 | 'pingTime': dateFormat(beaconHistory[i]['pingTime']), 183 | 'statusCode': beaconHistory[i]['statusCode'], 184 | 'wasSuccessful': (beaconHistory[i]['statusCode'] == 200) ? true : false 185 | }; 186 | beaconHistoryReadable.push(entry); 187 | } 188 | context = new Object(); 189 | context.beaconHistory = beaconHistoryReadable; 190 | context.url = normalizedUrl; 191 | this.body = yield render(this, 'healthchecks_sites', context); 192 | } 193 | 194 | logger.log('info', 'Attaching SIGINT listener.'); 195 | process.on('SIGINT', function() { 196 | process.exit(); 197 | }); 198 | 199 | if (!module.parent) { 200 | logger.log('info', 'App starting up...'); 201 | 202 | app.listen(3000); 203 | 204 | logger.log('info', 'App now listening on port.'); 205 | } 206 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pingdummy-frontend", 3 | "version": "0.0.1", 4 | "description": "Pingdummy Frontend Application", 5 | "dependencies": { 6 | "autoprefixer": "^6.3.6", 7 | "bootstrap-loader": "^1.0.10", 8 | "bootstrap-sass": "^3.3.6", 9 | "co-body": "^4.2.0", 10 | "co-views": "^2.1.0", 11 | "css-loader": "^0.23.1", 12 | "dateformat": "^1.0.12", 13 | "file-loader": "^0.8.5", 14 | "koa": "^1.2.0", 15 | "koa-flash": "^1.0.0", 16 | "koa-logger": "^1.3.0", 17 | "koa-mount": "^1.3.0", 18 | "koa-route": "^2.4.2", 19 | "koa-router": "^5.4.0", 20 | "koa-session": "^3.3.1", 21 | "koa-static": "^2.0.0", 22 | "koa-webpack-dev-middleware": "^1.2.1", 23 | "node-sass": "^3.7.0", 24 | "nodemon": "^1.9.2", 25 | "pingdummy-common": "file:../common", 26 | "pingdummy-db": "file:../db", 27 | "resolve-url-loader": "^1.4.3", 28 | "sass-loader": "^3.2.0", 29 | "sequelize": "^3.23.3", 30 | "style-loader": "^0.13.1", 31 | "swig": "^1.4.2", 32 | "tether": "^1.3.2", 33 | "url-loader": "^0.5.7", 34 | "webpack": "^1.13.1", 35 | "winston": "^2.2.0" 36 | }, 37 | "devDependencies": { 38 | "webpack-dev-server": "^1.14.1" 39 | }, 40 | "scripts": { 41 | "test": "echo \"Error: no test specified\" && exit 1", 42 | "start": "nodemon --watch index.js index.js", 43 | "clean": "rm -rf public/", 44 | "build": "npm run clean && ./node_modules/.bin/webpack --config webpack.config.js" 45 | }, 46 | "repository": { 47 | "type": "git", 48 | "url": "git+https://github.com/segmentio/stack.git" 49 | }, 50 | "author": "Segment Engineering Team ", 51 | "license": "MIT", 52 | "bugs": { 53 | "url": "https://github.com/segmentio/stack/issues" 54 | }, 55 | "homepage": "https://github.com/segmentio/stack#readme" 56 | } 57 | -------------------------------------------------------------------------------- /frontend/views/email_verify_registration.html: -------------------------------------------------------------------------------- 1 | Please verify your registration with pingdummy by following this link: 2 | 3 | {% autoescape false %}{{ verifyLink }}{% endautoescape %} 4 | 5 | Have a great day! 6 | -------------------------------------------------------------------------------- /frontend/views/email_verify_unsubscribe.html: -------------------------------------------------------------------------------- 1 | A request was sent to us to unsubscribe this email address from notifications for: 2 | 3 | {{ checkURL }} 4 | 5 | If you were the person that requested this and you'd still like to unsubscribe, use the link below: 6 | 7 | {{ unsubscribeURL }} 8 | -------------------------------------------------------------------------------- /frontend/views/healthchecks.html: -------------------------------------------------------------------------------- 1 | {% extends 'layout.html' %} 2 | 3 | {% block title %}Pingdummy: Health checks{% endblock title %} 4 | 5 | {% block content %} 6 | 7 |
8 | 9 |

10 |

11 |
12 | 13 | {% endblock content %} 14 | -------------------------------------------------------------------------------- /frontend/views/healthchecks_sites.html: -------------------------------------------------------------------------------- 1 | {% extends 'layout.html' %} 2 | 3 | {% block title %}Pingdummy: Health checks{% endblock title %} 4 | 5 | {% block content %} 6 | 7 |
8 | 9 |

10 | Health checks for: 11 |
{{ url }} 12 |

13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | {% for entry in beaconHistory %} 24 | 25 | 32 | 33 | 34 | 35 | {% endfor %} 36 | 37 |
Status codeTime
26 | {% if entry.wasSuccessful %} 27 | 28 | {% else %} 29 | 30 | {% endif %} 31 | {{ entry.statusCode }}{{ entry.pingTime }}
38 | 39 |
40 | 41 | {% endblock content %} 42 | -------------------------------------------------------------------------------- /frontend/views/home.html: -------------------------------------------------------------------------------- 1 | {% extends 'layout.html' %} 2 | 3 | {% block content %} 4 |
5 |

pingdummy

6 |

Pingdummy is the best way to get updates about your site's reliability.

7 | 8 |

Register

9 | {% include 'register_form.html' %} 10 |
11 | {% endblock content %} 12 | -------------------------------------------------------------------------------- /frontend/views/layout.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {% block title %}Pingdummy{% endblock %} 6 | 7 | 8 | 14 | 15 | 16 |
17 | {% if flash %} 18 |
19 |
22 | {% endif %} 23 |
24 | 25 |
26 | {% block content %} 27 |

Missing content!

28 | {% endblock content %} 29 |
30 | 31 | 32 | -------------------------------------------------------------------------------- /frontend/views/register.html: -------------------------------------------------------------------------------- 1 | {% extends 'layout.html' %} 2 | 3 | {% block title %}Pingdummy: Register{% endblock title %} 4 | 5 | {% block content %} 6 |

New Check

7 | {% include 'register_form.html' %} 8 | {% endblock content %} 9 | -------------------------------------------------------------------------------- /frontend/views/register_form.html: -------------------------------------------------------------------------------- 1 |
2 |

3 |

4 |

5 |
6 | -------------------------------------------------------------------------------- /frontend/views/success.html: -------------------------------------------------------------------------------- 1 | {% extends 'layout.html' %} 2 | 3 | {% block content %} 4 |

Verification link sent to {{ email }}. Please check your email!

5 | {% endblock content %} 6 | -------------------------------------------------------------------------------- /frontend/webpack.config.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | var autoprefixer = require('autoprefixer'); 3 | var webpack = require('webpack'); 4 | 5 | module.exports = { 6 | cache: true, 7 | entry: [ 8 | 'tether', 9 | 'bootstrap-loader', 10 | ], 11 | output: { 12 | path: path.join(__dirname, 'public', 'assets'), 13 | filename: 'app.js', 14 | publicPath: '/assets/', 15 | }, 16 | resolve: { 17 | extensions: [ '', '.js' ] 18 | }, 19 | 20 | plugins: [ 21 | new webpack.HotModuleReplacementPlugin(), 22 | new webpack.NoErrorsPlugin(), 23 | new webpack.ProvidePlugin({ 24 | "window.Tether": "tether" 25 | }), 26 | ], 27 | 28 | module: { 29 | loaders: [ 30 | { test: /\.css$/, loaders: [ 'style', 'css', 'postcss' ] }, 31 | { test: /\.scss$/, loaders: [ 'style', 'css', 'postcss', 'sass' ] }, 32 | { 33 | test: /\.woff2?(\?v=[0-9]\.[0-9]\.[0-9])?$/, 34 | loader: "url?limit=10000" 35 | }, 36 | { 37 | test: /\.(ttf|eot|svg)(\?[\s\S]+)?$/, 38 | loader: 'file' 39 | } 40 | ], 41 | }, 42 | 43 | postcss: [ autoprefixer ], 44 | } 45 | -------------------------------------------------------------------------------- /ops/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pingdummy-ops", 3 | "version": "0.0.1", 4 | "description": "Pingdummy Ops Tools", 5 | "scripts": { 6 | "test": "echo \"Error: no test specified\" && exit 1" 7 | }, 8 | "repository": { 9 | "type": "git", 10 | "url": "git+https://github.com/segmentio/pingdummy.git" 11 | }, 12 | "author": "Segment Engineering Team ", 13 | "license": "MIT", 14 | "bugs": { 15 | "url": "https://github.com/segmentio/pingdummy/issues" 16 | }, 17 | "homepage": "https://github.com/segmentio/pingdummy#readme", 18 | "dependencies": { 19 | "pingdummy-db": "file:../db", 20 | "pingdummy-common": "file:../common", 21 | "aws-sdk": "^2.3.19", 22 | "co": "^4.6.0" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /ops/setup.js: -------------------------------------------------------------------------------- 1 | var co = require('co'); 2 | var AWS = require('aws-sdk'); 3 | 4 | var common = require('pingdummy-common'); 5 | var config = common.config; 6 | var logger = common.logger; 7 | 8 | function *verifyFromAddress() { 9 | config.required(['notification_from_email']); 10 | var ses = new AWS.SES({apiVersion: '2010-12-01'}); 11 | var fromEmail = config.get('notification_from_email'); 12 | 13 | logger.log('info', 'Verifying notification from address', { email: fromEmail }); 14 | 15 | var getIdentityResponse = yield function(cb) { 16 | return ses.getIdentityVerificationAttributes({Identities: [fromEmail]}, cb); 17 | }; 18 | 19 | if (getIdentityResponse != null && 20 | fromEmail in getIdentityResponse.VerificationAttributes && 21 | getIdentityResponse.VerificationAttributes[fromEmail].VerificationStatus == 'Success') { 22 | logger.log('info', 'Email address already verified.'); 23 | return; 24 | } 25 | 26 | var verifyResponse = yield function(cb) { 27 | return ses.verifyEmailAddress({EmailAddress: fromEmail}, cb); 28 | }; 29 | 30 | logger.log('info', 'Sent verification, please click the link in the email from AWS.'); 31 | } 32 | 33 | co(function* () { 34 | yield verifyFromAddress(); 35 | }).catch(function(err) { 36 | logger.log('error', 'error verifying email address', err); 37 | }); 38 | -------------------------------------------------------------------------------- /terraform.tf: -------------------------------------------------------------------------------- 1 | module "stack" { 2 | source = "github.com/segmentio/stack" 3 | name = "pingdummy" 4 | environment = "prod" 5 | key_name = "bastion-ssh" 6 | } 7 | 8 | module "domain" { 9 | source = "github.com/segmentio/stack//dns" 10 | name = "pingdummy.com" 11 | } 12 | 13 | module "pingdummy" { 14 | source = "github.com/segmentio/stack//web-service" 15 | image = "segment/pingdummy" 16 | port = 3000 17 | ssl_certificate_id = "arn:aws:acm:us-west-2:458175278816:certificate/a5c477da-8ba1-4310-b081-eae6824b038a" 18 | 19 | environment = "${module.stack.environment}" 20 | cluster = "${module.stack.cluster}" 21 | iam_role = "${module.stack.iam_role}" 22 | security_groups = "${module.stack.external_elb}" 23 | subnet_ids = "${module.stack.external_subnets}" 24 | log_bucket = "${module.stack.log_bucket_id}" 25 | internal_zone_id = "${module.stack.zone_id}" 26 | external_zone_id = "${module.domain.zone_id}" 27 | 28 | env_vars = <", 14 | "license": "MIT", 15 | "bugs": { 16 | "url": "https://github.com/segmentio/stack/issues" 17 | }, 18 | "homepage": "https://github.com/segmentio/stack#readme", 19 | "dependencies": { 20 | "co": "^4.6.0", 21 | "co-request": "^1.0.0", 22 | "node-datetime": "^1.0.0", 23 | "nodemon": "^1.9.2", 24 | "pingdummy-common": "file:../common", 25 | "pingdummy-db": "file:../db", 26 | "winston": "^2.2.0" 27 | } 28 | } 29 | --------------------------------------------------------------------------------