X-Git-Url: https://git.kengrimes.com/?p=henge%2Fkiak.git;a=blobdiff_plain;f=router.js;fp=router.js;h=0000000000000000000000000000000000000000;hp=2085e9b166e411f9e46e4adadde35c8fe8fa1aaa;hb=552b28b4fc1ed42e3362c1826acf94c349425b1c;hpb=a7e6c36b91dd1a36021f7459200436b3cb31d756 diff --git a/router.js b/router.js deleted file mode 100644 index 2085e9b..0000000 --- a/router.js +++ /dev/null @@ -1,434 +0,0 @@ -/** - * @file HTTP(S) Router that treats the first directory in a URL's path as - * a route to a host. - * @author Ken Grimes - * @version 0.0.1 - * @license AGPL-3.0 - * @copyright Strapp.io - */ - -const dlog = (msg) => console.log(msg) - -exports = { - /** Regular expression for valid routes - * @prop {Object.RegEx} validRoutes - matches valid route names - */ - validRoutes: /[a-zA-Z][a-zA-Z0-9\-_]*/, - - /** A map of routes - * @prop {Object.Map} routes - all the routes! - */ - routes: {}, - - /** Parameters set on bootup (startHttpServer) - * @prop {string[2]} skelPage - html document split in twain for JS injection - * @prop {string} clientJS - jerverscripps to inject in skelPage for clients - * @prop {string} hostJS - jerverscripps for the hosts - * @prop {string} httpdRoot - a normalized path for http-servable files - * @prop {string} bindJail - jail bindings to this path - */ - skelPage: undefined, - clientJS: undefined, - hostJS: undefined, - httpdRoot: undefined, - bindJail: undefined, - - /** @func - * @summary Start main HTTP server - * @desc starts up an HTTP or HTTPS server used for routing - * @arg {Object} conf - object containing configuration properties - * @prop {number|string} conf.port - local system port to bind to - * @prop {string} conf.skelFile - location of the skeleton HTML page - * @prop {string} conf.clientJS - client JS file - * @prop {string} conf.hostJS - host JS file - * @prop {string} [conf.httpdRoot] - root path of http-accessible files, if - * undefined no files are accessible - * @prop {Object} [conf.tls] - if present, startHttpServer will use tls - * @prop {string} [conf.tls.certFile] - tls certificate file - * @prop {string} [conf.tls.keyFile] - tls public key file - */ - startHttpServer: function (conf) { - if ('httpd' in this) - throw new Error('httpd already running') - if (conf.tls == undefined) - this.httpd = require('http').createServer((req, res) => - this.httpdListener(req, res)) - else if (!('keyFile' in conf.tls) || !('certFile' in conf.tls)) - throw new Error('HTTPS requires a valid key and cert') - else - this.syncReads([conf.tls.keyFile, conf.tls.certFile]).then((results) => { - Object.defineProperty(this, 'httpsOpts', { - value: { - key: results[conf.tls.keyFile], - cert: results[conf.tls.certFile] - } - }) - this.httpd = - require('https').createServer(this.httpsOpts, (request,response) => - this.httpdListener(request,response)) - .listen(conf.port) - }) - if (conf.httpdRoot) { - this.httpdRoot = require('path').normalize(conf.httpdRoot) - while (this.httpdRoot[this.httpdRoot.length - 1] == require('path').sep) - this.httpdRoot = this.httpdRoot.slice(0,-1) - } - this.syncReads([conf.skelFile, conf.clientJS, conf.hostJS]) - .then((results) => { - this.skelPage = results[conf.skelFile].split('') - this.clientJS = results[conf.clientJS] - this.hostJS = results[conf.hostJS] - }) - .catch((err) => { - console.log(err) - }) - console.log(`HTTP${(conf.tls == undefined) ? '' : 'S'} ` + - `Server Started on port ${conf.port}${this.httpdRoot ? - `, serving files from ${this.httpdRoot}`:''}`) - }, - - /** @func - * @summary Create a binding for the server - * @desc makes a new route which is bound to a file or a path. routes - * bound to files always serve that file, regardless of any - * additional path parameters provided by the URI - * @arg {string} routeName - the route to create - * @arg {string} path - the path to the file or directory to bind - */ - createBind: function (routeName, path) { - dlog(`Binding ${routeName} to ${path}`) - if (routeName in this.routes) - throw new Error(`route ${routeName} already exists`) - path = require('path').normalize(path) - if (this.bindJail - && path.indexOf(`${this.bindJail}/`) !== 0 - && this.bindJail != path) - throw new Error(`${routeName}:${path} jailed to ${this.bindJail}`) - if (require('fs').existsSync(path)) { - this.routes[routeName] = { - bind: { - path: path, - dir: require('fs').lstatSync(path).isDirectory() - } - } - } - else - throw new Error(`${path} not found, ${routeName} not bound`) - }, - - /** @func - * @summary Router - * @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}`) - let htArgv = request.url.slice(1).split('?') - const routeName = htArgv[0].split('/')[0] - let route = this.routes[routeName] - /* If the route exists, check if we are a returning host or a new client */ - if (route) { - if (route.bind) { - htArgv[0] = htArgv[0].replace(`${routeName}`,'') - if (htArgv[0][0] === '/') - htArgv[0] = htArgv[0].slice(1) - this.serveBind(response, route.bind, htArgv) - } - //TODO: auth better than this (ip spoofing is easy) - // but this will require a more involved host-creation process - // that isn't just "give you a route if it's available" on visit - /* else if (route.origin == (request.headers['x-forwarded-for'] || - request.connection.remoteAddress)) - this.serveHost(response, route, htArgv) */ - else - this.serveClient(request, response, route) - } - /* If it's a valid routename that doesn't exist, make this client a host */ - else if (this.validRoutes.test(routeName)) { - this.routes[routeName] = true - require('get-port')() - .then((port) => { - this.createHost(routeName, htArgv, port, request, response) - }) - .catch((err) => { - delete this.routes[routeName] - console.log(err) - }) - } - /* Try servicing files if we have a root directory for it */ - else if (this.httpdRoot) { - let realPath = require('path').join(this.httpdRoot, htArgv[0]) - if (realPath == this.httpdRoot) - realPath += '/index.html' - if (realPath.indexOf(`${this.httpdRoot}/`) == 0) { - const stat_cb = (err, stat) => { - if (err) { - response.writeHead(404) - response.end() - console.log(err) - } - else if (stat.isDirectory()) { - realPath += '/index.html' - require('fs').lstat(realPath, stat_cb) - } - else if (stat.isFile()) - this.serveFile(response, realPath) - else { - response.writeHead(403) - response.end() - } - } - require('fs').lstat(realPath, stat_cb) - } - else { - response.writeHead(400) - response.end() - } - } - /* Unhandled */ - else { - response.writeHead(404) - response.end() - } - }, - - /** @func - * @summary Serves a binding to a client - * @desc Resolves a binding and serves a response to the client - * @arg {http.ServerResponse} response - the response to use - * @arg {Object} bind - the binding to serve the client - * @arg {string[]} argv - path and arguments for the bind - */ - serveBind: function (response, bind, argv) { - dlog(`Serving binding ${bind.path}/${argv[0]}`) - if (bind.dir) { - if (argv[0] == '') - argv[0] = 'index.html' - this.serveFile(response, require('path').join(bind.path, argv[0])) - } - else - this.serveFile(response, bind.path) - }, - - /** @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 GET'} 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 - case 'ice-candidate-request': - case 'ice-candidate-submission': - case 'client-sdp-offer': - let data = '' - if (pubKey) { - let data = request.headers['x-strapp-offer'] - route.pendingResponses.addResponse(pubKey, response) - dlog(`${route.origin}=>\n${pubKey}\n${type}`) - dlog(JSON.parse(data)) - route.socket.send(`${pubKey} ${type} ${data}`) - } - else { - response.writeHead(401) - response.end() - } - break - default: - response.writeHead(400) - response.end() - } - }, - - /** @func - * @summary Create a new route for a host - * @desc makes a new route for the given route name - * @arg {string} routeName - name of the new route - * @arg {string[]} argv - Origin address from the request that made this - * route (for security verification on the socket) - * @arg {number|string} port - the port to listen on for websocket - * @arg {http.ClientRequest} request - host's request - * @arg {http.ServerResponse} response - responder - */ - createHost: function (routeName, argv, port, request, response) { - const origin = (request.headers['x-forwarded-for'] || - request.connection.remoteAddress) - dlog(`New ${this.httpsOpts?'TLS ':''}route ${routeName}:${port}=>${origin}`) - const httpd = this.httpsOpts - ? require('https').createServer(this.httpsOpts) - : require('http').createServer() - const route = { - pendingResponses: new Map([]), - origin: origin, - httpd: httpd, - name: routeName, - port: port, - wsd: undefined, - socket: undefined - } - route.httpd.listen(port) - route.wsd = new (require('ws').Server)({ server: httpd }) - .on('connection', (socket) => { - route.socket = socket - socket.on('message', (msg) => - this.hostMessage(msg,route)) - }) - route.pendingResponses.addResponse = function (key, response_p) { - let responses = this.get(key) || [] - responses.push(response_p) - this.set(key, responses) - } - this.routes[routeName] = route - this.serveHost(response, route, argv) - }, - - /** @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) { - dlog(`Serving host ${route.origin}`) - 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 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: - * Forward Payload to Client) - * < 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 - * Translate SDP and Forward to Client) - * ^ clientKey sdp [header] - * Route the JSON object 'sdp' to the client, after translating - * for interop between browsers using planB or Unified. Other - * than the interop step, this is identical to the '<' command - * Error) - * ! 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) { - let argv = message.split(' ') - const command = argv[0][0] - argv = argv.slice(1) - dlog(`Received host message from ${route.name}: ${command}`) - switch (command) { - case '^': - if (argv.length < 2) { - dlog(`Malformed '${command}' command from ${route.origin}`) - route.socket.send(`! "Insufficient arguments" 0 ${message}`) - break - } - argv[1] = JSON.parse(argv[1]) - //TODO: interop step - argv[1] = JSON.stringify(argv[1]) - //TODO: argv[1] = encryptForClient(argv[0], argv[1]) - /* Fallthrough to '<' behavior after translating argv[1] */ - 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.origin}|${argv[1]}]:${argv[0]}`) - break - default: - route.socket.send(`! "Unknown command '${command}'" 0 ${message}`) - dlog(`Host ${route.origin} send unknown command: ${message}`) - 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 - relative location of the file - */ - serveFile: function (response, filePath, rootPath) { - //TODO: Make a buffer to hold recently used files, and only read if we - // have to (don't forget to preserve mimetype) - require('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() - }) - }, - - /** @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(`syncing reads from ${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) => - require('fs').readFile(file, readOpts, read_cb(file))) - }) -} - -module.exports = exports