Merge pull request #14 from yardstick/feature/QUANT-359-archimedes-integration-build-cors-compliant-web-server

QUANT-359 implement XhrListener to forward POSTs to WS
This commit is contained in:
Maciek Nowacki 2021-04-01 10:54:34 -06:00 committed by GitHub
commit ce2c65544c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 149 additions and 19 deletions

6
Jenkinsfile vendored
View File

@ -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: [
}
}
}
}
}

View File

@ -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)

View File

@ -1 +1 @@
1.2.3
1.3.0

View File

@ -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});
}

View File

@ -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'
};

View File

@ -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
};

View File

@ -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
};

104
src/xhrListener.ts Normal file
View File

@ -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;