diff --git a/Jenkinsfile b/Jenkinsfile index 077cc49..e1bf986 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -12,12 +12,12 @@ podTemplate(label: label, inheritFrom: 'base', , containers: [ stage('Install Dependencies') { container('base') { sh "npm install" - sh "npm install -g typescript" + sh "npm install typescript" } } stage('Build the Project') { container('base') { - sh "tsc" + sh "npx tsc" } } stage('Run unit tests') { @@ -44,4 +44,4 @@ podTemplate(label: label, inheritFrom: 'base', , containers: [ } } } -} \ No newline at end of file +} diff --git a/README.md b/README.md index 77c8bc3..3e615f5 100755 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# BRAID v1.2.3 +# BRAID v1.3.0 > Websocket server for the Measure platform [![Build Status](https://semaphoreci.com/api/v1/projects/7767f0f3-4da6-4c84-9167-4db5402a3262/2573412/badge.svg)](https://semaphoreci.com/yardstick/braid) diff --git a/docker_tag.txt b/docker_tag.txt index 0495c4a..f0bb29e 100644 --- a/docker_tag.txt +++ b/docker_tag.txt @@ -1 +1 @@ -1.2.3 +1.3.0 diff --git a/src/clients/clientBase.ts b/src/clients/clientBase.ts index 4139ac7..1ed82bb 100644 --- a/src/clients/clientBase.ts +++ b/src/clients/clientBase.ts @@ -51,7 +51,7 @@ class ClientBase { logger.accessLog.info(`client (${this.id}) ponged.`); this.ws.pong(); } - this.heartbeat = setInterval(() => { this.ws.ping('ping') }, 30000); + //this.heartbeat = setInterval(() => { this.ws.ping('ping') }, 30000); logger.accessLog.info('Client Created', {data}); } diff --git a/src/config/app.ts b/src/config/app.ts index 5887065..7c8c30b 100644 --- a/src/config/app.ts +++ b/src/config/app.ts @@ -1,5 +1,5 @@ module.exports = { - version : '1.2.3', + version : '1.3.0', whitelist : (process.env.WHITELIST || 'http://admin.localhost').split(','), secret : process.env.SECRET || 'test', devToken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJkYXRhIjp7ImNsaWVudCI6InRlc3QiLCJjbGllbnRfdHlwZSI6InNpdGUiLCJ1c2VyX3R5cGUiOiJ1c2VyIiwidXNlcl9pZCI6MjAwLCJjaGFubmVsIjoidGVzdF9jaGFubmVsIn0sImF1ZCI6ImludGVybmFsIiwiaXNzIjoiWWFyZHN0aWNrIFNvZnR3YXJlIiwic3ViIjoiQnJhaWQgSldUIn0.5KNCov_EW1cycT4Ay0oSvk4Z4PHFedd3bWOyqkHHTBQ', @@ -15,5 +15,6 @@ module.exports = { audience: 'internal', algorithm: ['HS256'] }, - messageTypes : ['broadcast', 'direct', 'changeChannel'] + messageTypes : ['broadcast', 'direct', 'changeChannel'], + archimedesToken: process.env.ARCHIMEDES_INTEGRATION_TOKEN || 'testkeyformeasure' }; diff --git a/src/logger.ts b/src/logger.ts index 3de53fc..e4b672f 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -7,27 +7,42 @@ const logPath = './logs/'; const tsFormat = () => (new Date().toISOString()); const logFormat = format.combine(format.timestamp(), format.json()); -function loggerTransports(logName: string, logLevel: string) { - return [ - new transports.File({ +function loggerTransports(logLevel: string, logName = "") { + let transport:any; + if ("" === logName) { + transport = new transports.Console({ + format: format.simple(), + level: logLevel, + timestamp: tsFormat + }); + } else { + transport = new transports.File({ filename: path.join(logPath, `${logName}.log`), timestamp: tsFormat, level: logLevel - }) - ]; + }); + } + + return [ transport ]; } +const debugLog = createLogger({ + format: format.simple(), + transports: loggerTransports('debug') +}); + const errorLog = createLogger({ format: logFormat, - transports: loggerTransports('errors', 'debug') + transports: loggerTransports('debug', 'errors') }); const accessLog = createLogger({ format: logFormat, - transports: loggerTransports('access', 'info') + transports: loggerTransports('info', 'access') }); module.exports = { errorLog, - accessLog + accessLog, + debugLog }; diff --git a/src/server.ts b/src/server.ts index 43923b1..d2a83e6 100755 --- a/src/server.ts +++ b/src/server.ts @@ -2,20 +2,29 @@ import * as WebSocket from 'ws'; import * as jwt from 'jsonwebtoken'; import * as url from 'url'; +import * as express from 'express'; // internal imports const app = require('./config/app'); const logger = require('./logger'); +const http = require('http'); import ClientManager from './clientManager'; import ChannelManager from './channelManager'; +import XhrListener from './xhrListener'; import PublicClient from './clients/types/publicClient'; import PrivateClient from './clients/types/privateClient'; import CustomClient from './clients/types/customClient'; -const wss = new WebSocket.Server({ maxPayload: 250000, port: app.port }); +const appExpress = express(); +const server = new http.createServer(); +// the port is no longer specified here as it induces WS to start running on its own +// instead, we do it later in XhrListener +const wss = new WebSocket.Server({ server: server, maxPayload: 250000 }); + const clientManager = new ClientManager(); const channelManager = new ChannelManager(); +const xhrListener = new XhrListener(channelManager, server, appExpress, express.json({type: 'application/json'})); function connectionManager() { wss.on('connection', (ws: WebSocket, request: any, args: string) => { @@ -63,7 +72,7 @@ function connectionManager() { channelManager.purgeEmptyChannels(); - ws.send(`Hi there, welcome to braid, Measures Web Socket server. Connecting all our services!\nYou are currently in channel: ${data.channel}`); + ws.send(`Hi there! welcome to braid, Measures Web Socket server. Connecting all our services!\nYou are currently in channel: ${data.channel}`); } }); } @@ -88,5 +97,6 @@ startServer(); module.exports = { clientManager, channelManager, - connectionManager + connectionManager, + xhrListener }; diff --git a/src/xhrListener.ts b/src/xhrListener.ts new file mode 100644 index 0000000..ecfa670 --- /dev/null +++ b/src/xhrListener.ts @@ -0,0 +1,104 @@ +import ChannelManager from './channelManager' + +const logger = require('./logger'); +const url = require('url'); +const http = require('http'); +const crypto = require('crypto'); +const app = require('./config/app'); +const cors = require('cors'); + +const AUTH_TOKEN = app.archimedesToken; +const HTTP_PORT = app.port; + +import { Server, IncomingMessage, ServerResponse } from 'http' +import * as bodyParser from "body-parser"; +interface ArchimedesMessage { + examId: string, + reservation_no: string +} + +class XhrListener { + #cm: ChannelManager; + + constructor(cm: ChannelManager, server: any, app: any, jsonParser: any) { + + this.#cm = cm; + logger.debugLog.info("XhrListener running"); + + //app.use(jsonParser); + + // turns out that we must compute HMAC based on the payload of the POST body, so: + app.use(bodyParser.json({ + verify: ((req: any, res: any, buf: any) => { req.rawBody = buf }) + })); + // TODO: try to pin down origin to reflect client rather than use wildcard + app.options('*', cors()); + + // I'm not entirely happy with bind(this) but it does let me break the + // callback and event handlers out into class methods. It will be + // problematic if the actual `this` needs to be accessed. + + app.post('/event/resume', cors(), + (req: any, res: any, next: any) => this.eventFromArchimedes.bind(this)("resumeExam", req, res)); + app.post('/event/pause', cors(), + (req: any, res: any, next: any) => this.eventFromArchimedes.bind(this)("pauseExam", req, res)); + + + server.on('request', app); + + // note that this kicks off HTTP listen for the entire app - not just HTTP but WS as well! + server.listen(HTTP_PORT, () => logger.debugLog.info(`braid is now listening for HTTP and WS connections on port ${HTTP_PORT}`)); + } + + public authorized(authOffered: String, rawBody: Buffer): boolean { + var hash = crypto.createHmac('sha1', AUTH_TOKEN).update(rawBody).digest("hex"); + + return authOffered.split("=").reverse()[0] === hash; + } + + public eventFromArchimedes(eventType: string, req: any, res: any) { + logger.debugLog.info(`eventFromArchimedes("${eventType}") called`); + let authorized: boolean = this.authorized(req.headers['x-proctoru-signature'], req.rawBody); + + if (!authorized) { + res.status(401); + res.send("Unauthorized"); + return; + } + + res.setHeader('Content-Type', 'application/json'); + let amsg: ArchimedesMessage = { 'examId': req.body.examId, 'reservation_no': req.body.reservation_no }; + if (this.relayEvent(eventType, amsg)) { + logger.debugLog.info('200 success relaying ' + eventType); + res.send(JSON.stringify(`${eventType} event was successfully relayed`)); + } else { + logger.errorLog.info(`XhrListener: could not relay ${eventType} event "${amsg}" to Exam UI; body: "${req.body}"`); + res.status(400); + res.send(JSON.stringify(`error while relaying ${eventType} event`)); + } + } + + public relayEvent(event: string, archMsg: ArchimedesMessage): boolean { + logger.debugLog.info(`XhrListener:relayEvent(event: ${event}, channel: ${archMsg.reservation_no})`); + for (let c of this.#cm.channels) { + if (archMsg.reservation_no == c.id) { + let dmCount: number = 0; + for (let client of c.clients) { + let nonce: string = crypto.randomBytes(4).toString("hex"); + // TODO: verify the nonce against the received reply, which we currently ignore + client.directMessage({ + message_type: "broadcast", + message: { event_type: event, seq_id: nonce, examId: archMsg.examId } + }); + dmCount++; + } + // dmCount of 1 would be normal. more than 1 is odd, but not necessarily bad. 0 means Exam UI has gone away somehow. + return dmCount > 0; + } + } + // did not match a channel; examId isn't valid for some reason + return false; + } +} + +export default XhrListener;