From 76f2586877c1ed21edf469bfe21cee9fcffe8310 Mon Sep 17 00:00:00 2001 From: ken Date: Sun, 9 Jul 2017 07:25:47 +0000 Subject: [PATCH] prototype --- router.js | 325 +++++++++++++++++++++++------------------------------- 1 file changed, 139 insertions(+), 186 deletions(-) diff --git a/router.js b/router.js index f17964b..4326453 100644 --- a/router.js +++ b/router.js @@ -16,6 +16,17 @@ exports = { */ 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 @@ -32,7 +43,7 @@ exports = { reject(err) else results[fileName] = data - if (++count == files.length) + if (++count === files.length) resolve(results) } if (readOpts == undefined) @@ -42,39 +53,89 @@ exports = { /** @func * @summary Main router listener - * @desc listens for client requests and services routes/files + * @desc listens for http client requests and services routes/files * @arg {http.ClientRequest} request * @arg {http.ServerResponse} response */ - listener: function (request,response) { + httpdListener: function (request,response) { dlog(`Received request ${request.method} ${request.url}`) const htArgv = request.url.slice(1).split('?') - let routePath = htArgv[0].split('/')[0] - let routeName = routePath[0] - let route = this.routes[routeName] + 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.serveRoute(response, route) + 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, routePath) + 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 {string} routeName - name of the route to follow + * @arg {Object} route - route associated with client request */ - serveRoute: function (response, routeName) { + 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 @@ -84,6 +145,7 @@ exports = { * @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}`) @@ -104,6 +166,10 @@ exports = { 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))) @@ -117,9 +183,12 @@ exports = { * and responds to either the host or the client, or both. Commands * are whitespace separated strings. * Commands: - * < clientKey requestID payload - * Route 'payload' to the client identified by 'clientKey', in - * response to the request identified by 'requestID' + * < 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 @@ -134,18 +203,23 @@ exports = { dlog(`Received host message from ${route.name}: ${command}`) switch (command) { case '<': - if (argv.length == 3) { - const response = route.pendingResponses.get(argv[0] + argv[1]) - response.writeHead(200, { 'Content-Type': 'application/octet-stream' }) - response.write(argv[2]) + 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 { + else route.socket.send(`! "Insufficient arguments" 0 ${message}`) - } break case '!': - if (argv.length == 3) + if (argv.length === 3) argv[0] += `\nIn message: ${argv[2]}` console.log(`Error[${route.host}|${argv[1]}]:${argv[0]}`) break @@ -160,22 +234,54 @@ exports = { * @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, tls) { + 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.listener) + 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 @@ -186,172 +292,19 @@ exports = { cert: results[tls.certfile] } }) - this.httpd = require('https').createServer(httpsOpts, this.listener) + 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}`) + `Server Started on port ${port}${this.httpdRoot ? ', serving' + + `files from ${this.httpdRoot}`:''}`) } } - -// exports.create = (opts) => { return { -// opts: opts, -// listener: function (request, response) { -// console.log('server handling request') -// const serveFile = (fPath) => { -// fs.readFile(fPath, { encoding: 'utf8' }, (err, data) => { -// if (err || data == undefined) { -// response.writeHead(404) -// response.end() -// } -// else { -// response.writeHead(200, { 'Content-Type': mime.lookup(fPath) }) -// response.write(data) -// response.end() -// } -// }) -// } -// const htArgv = request.url.slice(1).split("?") -// let routePath = htArgv[0].split('/') -// let routeName = routePath[0] - - -// if (routeName === '' || routeName === 'index.html') -// serveFile(opts['index']) -// else if (routeName in opts['bindings']) { -// let localPath = path.normalize(opts['bindings'][routeName].concat(path.sep + routePath.slice(1).join(path.sep))) -// if (localPath.includes(opts['bindings'][routeName])) { -// fs.readdir(localPath, (err, files) => { -// if (err) -// serveFile(localPath) -// else -// serveFile(`${localPath}/index.html`) -// }) -// } -// else { -// console.log(`SEC: ${localPath} references files not in route`) -// } -// } -// /* TODO: Handle reconnecting host */ -// else if (routeName in router.routes) { -// const route = router.routes[routeName] -// const clients = route['clients'] -// const headerData = request.headers['x-strapp-type'] - - - - -// /* Client is INIT GET */ -// if (headerData === undefined) { -// console.log('client init GET') -// response.writeHead(200, { 'Content-Type': 'text/html' }) -// response.write(`${router.skelPage[0]}${router.clientJS}${router.skelPage[1]}`) -// response.end() -// //TODO: if route.socket == undefined: have server delay this send until host connects -// // (this happens when a client connects to an active route with no currently-online host) -// } -// else if (headerData.localeCompare('ice-candidate-request') === 0) { -// console.log('Server: received ice-candidate-request from Client') -// let pubKey = request.headers['x-client-pubkey'] -// clients.set(pubKey, response) -// pubKey = '{ "pubKey": "' + pubKey + '" }' -// route.socket.send(pubKey) -// } -// else if (headerData.localeCompare('ice-candidate-submission') === 0) { -// console.log('Server: recieved ice-candidate-submission from Client') -// let data = [] -// request.on('data', (chunk) => { -// data.push(chunk) -// }).on('end', () => { -// console.log('Sending ice-candidate-submission to Host') -// data = Buffer.concat(data).toString(); -// clients.set(JSON.parse(data)['pubKey'], response) -// route.socket.send(data) -// }) -// } -// else if (headerData.localeCompare('client-sdp-offer') === 0){ /* Client sent offer, waiting for answer */ -// console.log('Server: Sending client offer to host') -// clients.set(JSON.parse(request.headers['x-client-offer'])['pubKey'], response) -// route.socket.send(request.headers['x-client-offer']) -// } else { -// console.log('Unhandled stuff') -// console.log(request.headers) -// } - -// } -// else { -// router.routes[routeName] = true -// const newRoute = {} -// newRoute.clients = new Map([]) -// newRoute.host = request.headers['x-forwarded-for'] || request.connection.remoteAddress -// getport().then( (port) => { -// newRoute.port = port -// if (opts['no-tls']) -// newRoute.httpd = http.createServer() -// else -// newRoute.httpd = https.createServer(router.httpsOpts) -// newRoute.httpd.listen(newRoute.port) -// newRoute.wsd = new ws.Server( { server: newRoute.httpd } ) -// newRoute.wsd.on('connection', (sock) => { -// console.log(`${routeName} server has been established`) -// newRoute.socket = sock - -// /* Handle all messages from host */ -// sock.on('message', (hostMessage) => { -// hostMessage = JSON.parse(hostMessage) -// response = newRoute.clients.get(hostMessage['clientPubKey']) - -// /* If the host response is a answer */ -// if (hostMessage['cmd'].localeCompare('< sdp pubKey') === 0) { -// console.log('Server: Sending host answer to client') -// response.writeHead(200, { 'Content-Type': 'application/json' }) -// response.write(JSON.stringify(hostMessage)) -// response.end() -// } -// else if (hostMessage['cmd'].localeCompare('< ice pubKey') === 0){ -// /* if the host response is an ice candidate */ -// console.log('Server: Handling host ICE message') -// let iceState = hostMessage['iceState'] -// /* If there are any ice candidates, send them back */ -// switch(iceState) { -// case "a": -// response.writeHead('200', {'x-strapp-type': 'ice-candidate-available'}) -// response.write(JSON.stringify(hostMessage)) -// response.end() -// break -// case "g": -// console.log('Server: Host is still gathering candidates, keep trying') -// response.writeHead('200', {'x-strapp-type': 'ice-state-gathering'}) -// response.write(JSON.stringify(hostMessage)) -// response.end() -// break -// case "c": -// console.log('Server: Host has completed gathering candidates') -// response.writeHead('200', {'x-strapp-type': 'ice-state-complete'}) -// response.write(JSON.stringify(hostMessage)) -// response.end() -// break -// default: -// console.log('unhandled iceState from host') -// break -// } -// } - -// }) -// }) - -// console.log(`Listening for websocket ${newRoute.host} on port ${newRoute.port}`) -// router.routes[routeName] = newRoute -// }).then(() => { -// response.writeHead(200, { 'Content-Type': 'text/html' }) -// response.write(`${router.skelPage[0]}` + -// `\tconst _strapp_port = ${newRoute.port}\n` + -// `\tconst _strapp_protocol = '${router.wsProtocol}'\n` + -// `${router.hostJS}\n${router.skelPage[1]}`) -// response.end() -// }) -// } -// }, -// startHttpd: () => require('http').createServer(this.listener) -// } - -- 2.18.0