/** * @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'} 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) 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) { const 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