X-Git-Url: https://git.kengrimes.com/?p=henge%2Fkiak.git;a=blobdiff_plain;f=router.js;h=4326453620a8884db282e425add6c2d41022c518;hp=2f0441ae6e4f28c721624dc9bca66ccf282ae905;hb=f7059ee7fc9e2fdfa48e80a13b2843ac08cec1dc;hpb=49487d947260500f71a7e125c705cef45e998db2 diff --git a/router.js b/router.js index 2f0441a..4326453 100644 --- a/router.js +++ b/router.js @@ -4,19 +4,307 @@ * @author Ken Grimes * @version 0.0.1 * @license AGPL-3.0 - * @copyright jk software 2017 + * @copyright Strapp.io */ const fs = require('fs') -module.exports = (opts) => { - const startHttpd = (listener) => { - if (opts['no-tls']) - return require('http').createServer(listener) - if (fs.existsSync(opts['ca-cert'])) - - } - return { - httpd: startHttpd(), - wsd: startWsd() +const dlog = (msg) => console.log(msg) + +exports = { + /** Regular expression for valid routes + * @var {Object.RegEx} validRoutes - matches valid route names + */ + validRoutes: /[a-zA-Z][a-zA-Z0-9\-_]*/, + + /** Parameters set on bootup (startHttpServer) + * @var {string[2]} skelPage - html document split in twain for JS injection + * @var {string} clientJS - jerverscripps to inject in skelPage for clients + * @var {string} hostJS - jerverscripps for the hosts + * @var {string} httpdRoot - a normalized path for http-servable files + */ + skelPage: undefined, + clientJS: undefined, + hostJS: undefined, + httpdRoot: undefined, + + /** @func + * @summary Synchronize Reading Multiple Files + * @desc reads an array of files into an object, whose keys are the + * input filenames, and values are the data read + * @arg {string[]} files - array of file names to read + * @arg {Object} [readOpts] - options to pass to fs.readFile() + */ + syncReads: (files, readOpts) => new Promise((resolve,reject) => { + dlog(`syncReads: ${files}`) + let count = 0 + let results = {} + const read_cb = (fileName) => (err, data) => { + if (err) + reject(err) + else + results[fileName] = data + if (++count === files.length) + resolve(results) + } + if (readOpts == undefined) + readOpts = { encoding: 'utf8' } + files.forEach((file) => fs.readFile(file, readOpts, read_cb(file))) + }), + + /** @func + * @summary Main router listener + * @desc listens for http client requests and services routes/files + * @arg {http.ClientRequest} request + * @arg {http.ServerResponse} response + */ + httpdListener: function (request,response) { + dlog(`Received request ${request.method} ${request.url}`) + const htArgv = request.url.slice(1).split('?') + const routeName = htArgv[0].split('/')[0] + const route = this.routes[routeName] + if (route) { + if (route.host == (request.headers['x-forwarded-for'] || + request.connection.remoteAddress)) + this.serveHost(response, route, htArgv) + else + this.serveClient(request, response, route) + } + else if (this.validRoutes.test(routeName)) { + route = this.createRoute(routeName, this.httpsOpts) + this.serveHost(response, route, htArgv) + } + else { + this.serveFile(response, htArgv[0]) + } + }, + + /** @func + * @summary Serve a route to an http client + * @desc routes may be bound to the filesystem, or to an outgoing host + * @arg {http.ClientRequest} request - request from the client + * @arg {http.ServerResponse} response - response object to use + * @arg {Object} route - route associated with client request + */ + serveClient: function (request, response, route) { + const type = request.headers['x-strapp-type'] + const pubKey = request.headers['x-strapp-pubkey'] + dlog(`Client ${type || 'HT'} request routed to ${route.name}`) + switch (type) { + case null: + case undefined: + response.writeHead(200, { 'Content-Type': 'text/html' }) + response.write(`${this.skelPage[0]}${this.clientJS}${this.skelPage[1]}`) + response.end() + break + if (pubKey) { + route.pendingResponses.addResponse(pubKey, response) + route.socket.send(`${type} ${pubKey}`) + } + break + case 'ice-candidate-request': + case 'ice-candidate-submission': + case 'client-sdp-offer': + let data = '' + if (pubKey) { + route.pendingResponses.addResponse(pubKey, response) + request.on('data', (chunk) => data += chunk) + request.on('end', () => route.socket.send(`${pubKey} ${type} ${data}`)) + } + else { + response.writeHead(401) + response.end() + } + break + default: + response.writeHead(400) + response.end() + } + }, + + /** @func + * @summary Serve a route to an authorized http host + * @desc services host application to the client, establishing a socket + * @arg {http.ServerResponse} response - response object to use + * @arg {Object} route - the route that belongs to this host + * @arg {string[]} argv - vector of arguments sent to the host + */ + serveHost: function (response, route, argv) { + response.writeHead(200, { 'Content-Type': 'text/html' }) + response.write(`${this.skelPage[0]}` + + `\tconst _strapp_port = ${route.port}\n` + + `\tconst _strapp_protocol = ` + + `${this.httpsOpts ? 'wss' : 'ws'}'\n` + + `${this.hostJS}\n${this.skelPage[1]}`) + response.end() + }, + + /** @func + * @summary Create a new route + * @desc makes a new route for the given route name + * @arg {string} routeName - name of the new route + * @arg {string} host - Origin address from the request that made this + * route (for security verification on the socket) + * @arg {Object} [httpsOpts] - key and cert for tls + * @returns {Object} a route object containing host, socket, and servers + */ + createRoute: function (routeName, host, httpsOpts) { + dlog(`Creating ${httpsOpts ? 'TLS ' : ''}route ${routeName} from ${host}`) + if (routeName in this.routes) + throw new Error(`route ${routeName} already exists`) + const httpd = httpsOpts + ? require('https').createServer(httpsOpts) + : require('http').createServer() + const wsd = new require('ws').Server({ + server: httpd, + verifyClient: (info) => info.origin == host && (info.secure || !httpsOpts) + }) + const route = { + pendingResponses: new Map([]), + host: host, + httpd: httpd, + wsd: wsd, + name: routeName, + socket: undefined + } + route.pendingResponses.addResponse = function (key, response) { + let responses = this.get(key) || [] + this.set(key, responses.push(response)) + } + wsd.on('connection', (socket) => + socket.on('message', (msg) => + this.hostMessage(msg,route))) + this.routes[routeName] = route + return route + }, + + /** @func + * @summary handle host message + * @desc receives a message from a host, handles the command (first character), + * and responds to either the host or the client, or both. Commands + * are whitespace separated strings. + * Commands: + * < clientKey payload [header] + * Route 'payload' to the client identified by 'clientKey'. + * The optional 'header' argument is a stringified JSON object, + * which will be written to the HTTP response header + * In case of multiple requests from a single client, the + * oldest request will be serviced on arrival of message + * ! errorMessage errorCode [offendingMessage] + * Notify host that an error has occured, providing a message + * and error code. 'offendingMessage', if present, is the + * message received from the remote that triggered the error. + * @arg {string} message - raw string from the host + * @arg {Object} route - the route over + */ + hostMessage: function (message, route) { + const argv = message.split(' ') + const command = argv[0][0] + argv = argv.slice(1) + dlog(`Received host message from ${route.name}: ${command}`) + switch (command) { + case '<': + const response = route.pendingResponses.get(argv[0]).shift() + if (!response) + route.socket.send(`! "No pending responses for client ${argv[0]}" 0 ` + + message) + else if (argv.length === 2 || argv.length === 3) { + const header = argv.length === 3 ? JSON.parse(argv[2]) : {} + if (!('Content-Type' in header)) + header['Content-Type'] = 'application/octet-stream' + response.writeHead(200, header) + response.write(argv[1]) + response.end() + } + else + route.socket.send(`! "Insufficient arguments" 0 ${message}`) + break + case '!': + if (argv.length === 3) + argv[0] += `\nIn message: ${argv[2]}` + console.log(`Error[${route.host}|${argv[1]}]:${argv[0]}`) + break + } + }, + + /** @func + * @summary Serve a file to an http client after a request + * @desc reads files from the system to be distributed to clients, and + * buffers recently accessed files + * @arg {http.ServerResponse} response - the response object to use + * @arg {string} filePath - location of the file on disk to service + */ + serveFile: function (response, filePath) { + if (this.clientCanAccessFile(filePath)) { + //TODO: Make a buffer to hold recently used files, and only read if we + // have to (don't forget to preserve mimetype) + fs.readFile(filePath, { encoding: 'utf8' }, (err, data) => { + if (err || data == undefined) + response.writeHead(404) + else { + response.writeHead(200, { + 'Content-Type': require('mime').lookup(filePath) + }) + response.write(data) + } + response.end() + }) + } + else { + response.writeHead(403) + response.end() + } + }, + + /** @func + * @summary Test if client can access a file + * @return {Bool} true if the filePath is authorized + */ + clientCanAccessFile: (filePath) => require('path') + .normalize(this.httpdRoot + filePath) + .indexOf(this.httpdRoot + '/') === 0, + + /** @func + * @summary Start main HTTP server + * @desc starts up an HTTP or HTTPS server used for routing + * @arg {number|string} port - local system port to bind to + * @arg {string} skelFile - location of the skeleton HTML page to use + * @arg {string} clientJS - location of the client's JS distributable + * @arg {string} hostJS - location of the host's JS distributable + * @arg {string} [httpdRoot] - root path of http-accessible files, if not + * provided no files will be accessible + * @arg {Object} [tls] - if present, startHttpServer will start in tls + * mode. supported properties: + * 'certfile': certificate file location + * 'keyfile': key file location + */ + startHttpServer: function (port, skelFile, clientJS, hostJS, httpdRoot, tls) { + if ('httpd' in this) + throw new Error('httpd already running') + if (tls == undefined) + this.httpd = require('http').createServer(this.httpdListener) + else if (!('key' in tls) || !('cert' in tls)) + throw new Error('HTTPS requires a valid key and cert') + else + this.syncReads([tls.keyfile, tls.certfile]).then((results) => { + Object.defineProperty(this, 'httpsOpts', { + value: { + key: results[tls.keyfile], + cert: results[tls.certfile] + } + }) + this.httpd = + require('https').createServer(httpsOpts, this.httpdListener) + }) + this.httpd.listen(port) + this.httpdRoot = httpdRoot ? require('path').normalize(httpdRoot):undefined + while (this.httpdRoot[this.httpdRoot.length - 1] == require('path').sep) + this.httpdRoot = this.httpdRoot.slice(0,-1) + this.skelPage = fs.readFileSync('./skel.html', { encoding: 'utf8' }) + .split('') + this.hostJS = fs.readFileSync(hostJS) + this.clientJS = fs.readFileSync(clientJS) + console.log(`HTTP${(tls == undefined) ? 'S' : ''} ` + + `Server Started on port ${port}${this.httpdRoot ? ', serving' + + `files from ${this.httpdRoot}`:''}`) } }