0.0.4
[henge/kiak.git] / old / router-new.js
1 /**
2 * @file HTTP(S) Router that treats the first directory in a URL's path as
3 * a route to a host.
4 * @author Ken Grimes
5 * @version 0.0.2
6 * @license AGPL-3.0
7 * @copyright Strapp.io
8 */
9
10 const dlog = (msg) => console.log(msg)
11
12
13 exports = {
14
15 }
16
17 exports = {
18 /** Regular expression for valid routes
19 * @prop {Object.RegEx} validRoutes - matches valid route names
20 */
21 validRoutes: /[a-zA-Z][a-zA-Z0-9\-_]*/,
22
23 /** HTML to distribute initially during bootstrapping process
24 * @prop {string} bootStrapp - raw HTML distributed to all clients
25 * @prop {string} bootStrappJS - raw JS distributed to all clients
26 */
27 bootStrapp: '',
28 bootStrappJS: '',
29
30 /** A map of routes
31 * @prop {Object.Map} routes - all the routes!
32 */
33 routes: {},
34
35 /** Parameters set on bootup (startHttpServer)
36 * @prop {string[2]} skelPage - html document split in twain for JS injection
37 * @prop {string} clientJS - jerverscripps to inject in skelPage for clients
38 * @prop {string} hostJS - jerverscripps for the hosts
39 * @prop {string} httpdRoot - a normalized path for http-servable files
40 * @prop {string} bindJail - jail bindings to this path
41 */
42 skelPage: undefined,
43 clientJS: undefined,
44 hostJS: undefined,
45 httpdRoot: undefined,
46 bindJail: undefined,
47
48 /** @func
49 * @summary Start main HTTP server
50 * @desc starts up an HTTP or HTTPS server used for routing
51 * @arg {Object} conf - object containing configuration properties
52 * @prop {number|string} conf.port - local system port to bind to
53 * @prop {string} conf.skelFile - location of the skeleton HTML page
54 * @prop {string} conf.clientJS - client JS file
55 * @prop {string} conf.hostJS - host JS file
56 * @prop {string} [conf.httpdRoot] - root path of http-accessible files, if
57 * undefined no files are accessible
58 * @prop {Object} [conf.tls] - if present, startHttpServer will use tls
59 * @prop {string} [conf.tls.certFile] - tls certificate file
60 * @prop {string} [conf.tls.keyFile] - tls public key file
61 */
62 startHttpServer: function (conf) {
63 if ('httpd' in this)
64 throw new Error('httpd already running')
65 if (conf.tls == undefined)
66 this.httpd = require('http').createServer((req, res) =>
67 this.httpdListener(req, res))
68 else if (!('keyFile' in conf.tls) || !('certFile' in conf.tls))
69 throw new Error('HTTPS requires a valid key and cert')
70 else
71 this.syncReads([conf.tls.keyFile, conf.tls.certFile]).then((results) => {
72 Object.defineProperty(this, 'httpsOpts', {
73 value: {
74 key: results[conf.tls.keyFile],
75 cert: results[conf.tls.certFile]
76 }
77 })
78 this.httpd =
79 require('https').createServer(this.httpsOpts, (request,response) =>
80 this.httpdListener(request,response))
81 .listen(conf.port)
82 })
83 if (conf.httpdRoot) {
84 this.httpdRoot = require('path').normalize(conf.httpdRoot)
85 while (this.httpdRoot[this.httpdRoot.length - 1] == require('path').sep)
86 this.httpdRoot = this.httpdRoot.slice(0,-1)
87 }
88 this.syncReads([conf.skelFile, conf.clientJS, conf.hostJS])
89 .then((results) => {
90 this.skelPage = results[conf.skelFile].split('<!--STRAPP_SRC-->')
91 this.clientJS = results[conf.clientJS]
92 this.hostJS = results[conf.hostJS]
93 })
94 .catch((err) => {
95 console.log(err)
96 })
97 console.log(`HTTP${(conf.tls == undefined) ? '' : 'S'} ` +
98 `Server Started on port ${conf.port}${this.httpdRoot ?
99 `, serving files from ${this.httpdRoot}`:''}`)
100 },
101
102 /** @func
103 * @summary Create a binding for the server
104 * @desc makes a new route which is bound to a file or a path. routes
105 * bound to files always serve that file, regardless of any
106 * additional path parameters provided by the URI
107 * @arg {string} routeName - the route to create
108 * @arg {string} path - the path to the file or directory to bind
109 */
110 createBind: function (routeName, path) {
111 dlog(`Binding ${routeName} to ${path}`)
112 if (routeName in this.routes)
113 throw new Error(`route ${routeName} already exists`)
114 path = require('path').normalize(path)
115 if (this.bindJail
116 && path.indexOf(`${this.bindJail}/`) !== 0
117 && this.bindJail != path)
118 throw new Error(`${routeName}:${path} jailed to ${this.bindJail}`)
119 if (require('fs').existsSync(path)) {
120 this.routes[routeName] = {
121 bind: {
122 path: path,
123 dir: require('fs').lstatSync(path).isDirectory()
124 }
125 }
126 }
127 else
128 throw new Error(`${path} not found, ${routeName} not bound`)
129 },
130
131 /** @func
132 * @summary Router
133 * @desc listens for http client requests, authorization for hosts, message
134 * relaying with strapp API over HTTP
135 * @arg {http.ClientRequest} request
136 * @arg {http.ServerResponse} response
137 */
138 httpdListener: function (request,response) {
139 dlog(`Received request ${request.method} ${request.url}`)
140
141 /* No strapp type: serve bootstrapp.html and bootstrapp.js only */
142 if (route.url) {
143 if (request.method !== 'GET')
144 response.writeHead(405)
145 else if (/^\/bootstrapp\.js[^/]*/.test(request.url)) {
146 response.writeHead(200, { 'Content-Type': 'application/javascript' })
147 response.write(this.bootStrappJS)
148 }
149 else {
150 response.writeHead(200, { 'Content-Type': 'text/html' })
151 response.write(this.bootStrapp)
152 }
153 response.end()
154 return
155 }
156
157 const uri = request.url.slice(1).split('?')
158 const routeName = uri[0].split('/')[0]
159
160 /* Process strapp types that make sense without a route */
161 if (routeName === '') {
162 switch (type) {
163 case 'route-list': //GET
164 this.serveRouteList()
165 break
166 case 'register-account': //PUT
167 break
168 default:
169 dlog(`x-strapp-type: ${type} not valid without a route`)
170 response.writeHead(400)
171 response.end()
172 }
173 return
174 }
175 /* Handle invalid routenames (serve files if httpdRoot is defined) */
176 else if (!this.validRoutes.test(routeName)) {
177 if (this.httpdRoot) {
178 let realPath = require('path').join(this.httpdRoot, uri[0])
179 if (realPath == this.httpdRoot)
180 realPath += '/index.html'
181 if (realPath.indexOf(`${this.httpdRoot}/`) == 0) {
182 const stat_cb = (err, stat) => {
183 if (err) {
184 dlog(err)
185 response.writeHead(404)
186 response.end()
187 }
188 else if (stat.isDirectory()) {
189 realPath += '/index.html'
190 require('fs').lstat(realPath, stat_cb)
191 }
192 else if (stat.isFile())
193 this.serveFile(response, realPath)
194 else {
195 response.writeHead(403)
196 response.end()
197 }
198 }
199 require('fs').lstat(realPath, stat_cb)
200 return
201 }
202 dlog(`Erroneous file location ${realPath} from ${request.url}`)
203 }
204 response.writeHead(400)
205 response.end()
206 return
207 }
208 /* Route does not exist */
209 else if (!(routeName in this.routes)) {
210 response.writeHead(404)
211 response.end()
212 return
213 }
214
215 const route = this.routes[routeName]
216 const authData = request.headers['Authorization']
217 switch (request.method) {
218 /* Public Requests */
219 case 'POST': //forward message to route
220 this.forward(route, authData, type, request.headers['x-strapp-data'])
221 if (route.online) {
222 route.socket.send(JSON.stringify(
223 ['sdpOffer', {
224 sdp: request.headers['x-sdp'],
225 pubKey: pubKey,
226 responseID: route.nextResponseID(response)
227 }]))
228
229 }
230 break
231 /* Authorization Required */
232 case 'make-socket': //CONNECT
233 this.authRouteOwner(route, authData)
234 .then(() => this.servePort(route, response))
235 .catch(() => this.serveHead(response, 400))
236 break
237 case 'app-list': //TRACE
238 this.authRouteOwner(route, authData)
239 .then(() => this.serveAppList(response))
240 .catch(() => this.serveHead(response, 400))
241 break
242 case 'app': //GET
243 const app = request.headers['x-strapp-app']
244 if (app && app !== '' && app in this.appList) {
245 this.authRouteOwner(route, authData)
246 .then(() => this.serveApp(request.headers['x-strapp-app'], response))
247 .catch(() => this.serveHead(response, 400))
248 }
249 else
250 this.serveHead(response, 404)
251 break
252 default:
253 this.serveHead(response, 400)
254 dlog(`Unrecognized x-strapp-type: ${type}`)
255 break
256 }
257
258 let htArgv = request.url.slice(1).split('?')
259 // const routeName = htArgv[0].split('/')[0]
260
261 /* At the root (no route selected) */
262 if (routeName === '') {
263 if (type === 'init')
264 this.serveSplash(response)
265 else {
266 dlog(`${type} request at null route`)
267 response.writeHead(400)
268 response.end()
269 }
270 return
271 }
272
273 /* TODO: A new client account is registered */
274 if (type === 'register-account') {
275 dlog("Not implemented")
276 response.writeHead(501)
277 response.end()
278 return
279 }
280
281 let route = this.routes[routeName]
282
283 /* TODO: A host, who needs authed, is requesting a route registration */
284 if (type === 'register-route') {
285 dlog("Not implemented")
286 response.writeHead(501)
287 response.end()
288 return
289 }
290 /* TODO: Register a route to a host account */
291 if (type === 'register-route-answer') {
292 this.routes[routeName] = true
293 require('get-port')()
294 .then((port) => {
295 this.createHost(routeName, htArgv, port, request, response)
296 })
297 .catch((err) => {
298 delete this.routes[routeName]
299 console.log(err)
300 })
301 }
302 /* Route exists, but is bound to a directory */
303 if (route && route.bind) {
304 htArgv[0] = htArgv[0].replace(`${routeName}`,'')
305 if (htArgv[0][0] === '/')
306 htArgv[0] = htArgv[0].slice(1)
307 this.serveBind(response, route.bind, htArgv)
308 return
309 }
310
311 /* Route may or may not be registered, and may or may not be online */
312 switch (type) {
313 case 'init':
314 if (!route)
315 this.serveSplash(response, routeName)
316 else if (route.online)
317 route.socket.send(JSON.stringify(
318 ['clientRequest', {
319 pubKey: pubKey,
320 responseID: route.nextResponseID(response),
321 argv: htArgv
322 }]))
323 else {
324 response.writeHead(503)
325 response.end()
326 }
327 break
328 case 'sdp-request':
329 if (route && route.online)
330 route.socket.send(JSON.stringify(
331 ['sdpOffer', {
332 sdp: request.headers['x-sdp'],
333 pubKey: pubKey,
334 responseID: route.nextResponseID(response)
335 }]))
336 else {
337 response.writeHead(503)
338 response.end()
339 }
340 break
341 case 'ice-candidate':
342 if (route && route.online)
343 route.socket.send(JSON.stringify(
344 ['iceCandidate', {
345 ice: request.headers['x-ice'],
346 pubKey: pubKey,
347 responseID: route.nextResponseID(response)
348 }]))
349 else {
350 response.writeHead(503)
351 response.end()
352 }
353 break
354 case 'account-create' :
355 dlog("Not implemented")
356 response.writeHead(501)
357 response.end()
358 break
359 case 'auth':
360 case 'host-login':
361 if (!route)
362 response.writeHead(404)
363 else if (pubKey != route.pubKey)
364 response.writeHead(401)
365 else if (route.pendingSecret) {
366 response.writeHead(409)
367 if (route.timeout === undefined)
368 route.timeout = setTimeout(() => {
369 delete route.pendingSecret
370 delete route.timeout
371 }, 30000)
372 }
373 else {
374 response.writeHead(200, { 'Content-Type': 'application/json' })
375 route.pendingSecret = this.nextSecret()
376 response.write(JSON.stringify(
377 ['serverAuth', {
378 secret: this.encrypt(route.pendingSecret, route.pubKey),
379 pubKey: this.pubKey
380 }]))
381 }
382 response.end()
383 break
384 case 'auth-answer':
385 case 'host-login-answer':
386 const answer = request.headers['x-strapp-answer']
387 if (!route || pubKey != route.pubKey || !route.pendingSecret) {
388 response.writeHead(400)
389 response.end()
390 }
391 else if (answer && this.decrypt(answer) === route.pendingSecret) {
392 route.socket.close(1,'host reconnected')
393 route.httpd.close()
394 this.serveSocket(response, route)
395 delete route.pendingSecret
396 }
397 else {
398 response.writeHead(401)
399 response.end()
400 }
401 break
402 case 'route-connect':
403 break
404 default:
405 dlog(`Unrecognized x-strapp-type: ${type}`)
406 break
407 }
408 },
409
410 /** @func
411 * @summary Serves a binding to a client
412 * @desc Resolves a binding and serves a response to the client
413 * @arg {http.ServerResponse} response - the response to use
414 * @arg {Object} bind - the binding to serve the client
415 * @arg {string[]} argv - path and arguments for the bind
416 */
417 serveBind: function (response, bind, argv) {
418 dlog(`Serving binding ${bind.path}/${argv[0]}`)
419 if (bind.dir) {
420 if (argv[0] == '')
421 argv[0] = 'index.html'
422 this.serveFile(response, require('path').join(bind.path, argv[0]))
423 }
424 else
425 this.serveFile(response, bind.path)
426 },
427
428 /** @func
429 * @summary Serve a route to an http client
430 * @desc routes may be bound to the filesystem, or to an outgoing host
431 * @arg {http.ClientRequest} request - request from the client
432 * @arg {http.ServerResponse} response - response object to use
433 * @arg {Object} route - route associated with client request
434 */
435 serveClient: function (request, response, route) {
436 const type = request.headers['x-strapp-type']
437 const pubKey = request.headers['x-strapp-pubkey']
438 dlog(`Client ${type || 'HT GET'} request routed to ${route.name}`)
439 switch (type) {
440 case null:
441 case undefined:
442 response.writeHead(200, { 'Content-Type': 'text/html' })
443 response.write(`${this.skelPage[0]}${this.clientJS}${this.skelPage[1]}`)
444 response.end()
445 break
446 case 'ice-candidate-request':
447 case 'ice-candidate-submission':
448 case 'client-sdp-offer':
449 let data = ''
450 if (pubKey) {
451 let data = request.headers['x-strapp-offer']
452 route.pendingResponses.addResponse(pubKey, response)
453 dlog(`${route.origin}=>\n${pubKey}\n${type}`)
454 dlog(JSON.parse(data))
455 route.socket.send(`${pubKey} ${type} ${data}`)
456 }
457 else {
458 response.writeHead(401)
459 response.end()
460 }
461 break
462 default:
463 response.writeHead(400)
464 response.end()
465 }
466 },
467
468 /** @func
469 * @summary Create a new route for a host
470 * @desc makes a new route for the given route name
471 * @arg {string} routeName - name of the new route
472 * @arg {string[]} argv - Origin address from the request that made this
473 * route (for security verification on the socket)
474 * @arg {number|string} port - the port to listen on for websocket
475 * @arg {http.ClientRequest} request - host's request
476 * @arg {http.ServerResponse} response - responder
477 */
478 createHost: function (routeName, argv, port, request, response) {
479 const origin = (request.headers['x-forwarded-for'] ||
480 request.connection.remoteAddress)
481 dlog(`New ${this.httpsOpts?'TLS ':''}route ${routeName}:${port}=>${origin}`)
482 const httpd = this.httpsOpts
483 ? require('https').createServer(this.httpsOpts)
484 : require('http').createServer()
485 const route = {
486 pendingResponses: new Map([]),
487 origin: origin,
488 httpd: httpd,
489 name: routeName,
490 port: port,
491 wsd: undefined,
492 socket: undefined,
493 online: false
494 }
495 route.httpd.listen(port)
496 route.wsd = new (require('ws').Server)({ server: httpd })
497 .on('connection', (socket) => {
498 route.socket = socket
499 socket.on('message', (msg) =>
500 this.hostMessage(msg,route))
501 })
502 route.pendingResponses.addResponse = function (key, response_p) {
503 let responses = this.get(key) || []
504 responses.push(response_p)
505 this.set(key, responses)
506 }
507 this.routes[routeName] = route
508 this.serveHost(response, route, argv)
509 },
510
511 /** @Func
512 * @summary Serve a route to an authorized http host
513 * @desc services host application to the client, establishing a socket
514 * @arg {http.ServerResponse} response - response object to use
515 * @arg {Object} route - the route that belongs to this host
516 * @arg {string[]} argv - vector of arguments sent to the host
517 */
518 serveHost: function (response, route, argv) {
519 dlog(`Serving host ${route.origin}`)
520 response.writeHead(200, { 'Content-Type': 'text/html' })
521 response.write(`${this.skelPage[0]}` +
522 `\tconst _strapp_port = ${route.port}\n` +
523 `\tconst _strapp_protocol = ` +
524 `'${this.httpsOpts ? 'wss' : 'ws'}'\n` +
525 `${this.hostJS}\n${this.skelPage[1]}`)
526 response.end()
527 },
528
529 /** @func
530 * @summary handle host message
531 * @desc receives a message from a host, handles the command (first character),
532 * and responds to either the host or the client, or both. Commands
533 * are whitespace separated strings.
534 * Commands:
535 * Forward Payload to Client)
536 * < clientKey payload [header]
537 * Route 'payload' to the client identified by 'clientKey'.
538 * The optional 'header' argument is a stringified JSON object,
539 * which will be written to the HTTP response header
540 * In case of multiple requests from a single client, the
541 * oldest request will be serviced on arrival of message
542 * Translate SDP and Forward to Client)
543 * ^ clientKey sdp [header]
544 * Route the JSON object 'sdp' to the client, after translating
545 * for interop between browsers using planB or Unified. Other
546 * than the interop step, this is identical to the '<' command
547 * Error)
548 * ! errorMessage errorCode [offendingMessage]
549 * Notify host that an error has occured, providing a message
550 * and error code. 'offendingMessage', if present, is the
551 * message received from the remote that triggered the error.
552 * @arg {string} message - raw string from the host
553 * @arg {Object} route - the route over
554 */
555 hostMessage: function (message, route) {
556 let argv = message.split(' ')
557 const command = argv[0][0]
558 argv = argv.slice(1)
559 dlog(`Received host message from ${route.name}: ${command}`)
560 switch (command) {
561 case '^':
562 if (argv.length < 2) {
563 dlog(`Malformed '${command}' command from ${route.origin}`)
564 route.socket.send(`! "Insufficient arguments" 0 ${message}`)
565 break
566 }
567 argv[1] = JSON.parse(argv[1])
568 //TODO: interop step
569 argv[1] = JSON.stringify(argv[1])
570 //TODO: argv[1] = encryptForClient(argv[0], argv[1])
571 /* Fallthrough to '<' behavior after translating argv[1] */
572 case '<':
573 const response = route.pendingResponses.get(argv[0]).shift()
574 if (!response)
575 route.socket.send(`! "No pending responses for client ${argv[0]}" 0 `
576 + message)
577 else if (argv.length === 2 || argv.length === 3) {
578 const header = argv.length === 3 ? JSON.parse(argv[2]) : {}
579 if (!('Content-Type' in header))
580 header['Content-Type'] = 'application/octet-stream'
581 response.writeHead(200, header)
582 response.write(argv[1])
583 response.end()
584 }
585 else
586 route.socket.send(`! "Insufficient arguments" 0 ${message}`)
587 break
588 case '!':
589 if (argv.length === 3)
590 argv[0] += `\nIn message: ${argv[2]}`
591 console.log(`Error[${route.origin}|${argv[1]}]:${argv[0]}`)
592 break
593 default:
594 route.socket.send(`! "Unknown command '${command}'" 0 ${message}`)
595 dlog(`Host ${route.origin} send unknown command: ${message}`)
596 break
597 }
598 },
599
600 /** @func
601 * @summary Serve a file to an http client after a request
602 * @desc reads files from the system to be distributed to clients, and
603 * buffers recently accessed files
604 * @arg {http.ServerResponse} response - the response object to use
605 * @arg {string} filePath - relative location of the file
606 */
607 serveFile: function (response, filePath, rootPath) {
608 //TODO: Make a buffer to hold recently used files, and only read if we
609 // have to (don't forget to preserve mimetype)
610 require('fs').readFile(filePath, { encoding: 'utf8' }, (err, data) => {
611 if (err || data === undefined)
612 response.writeHead(404)
613 else {
614 response.writeHead(200, {
615 'Content-Type': require('mime').lookup(filePath)
616 })
617 response.write(data)
618 }
619 response.end()
620 })
621 },
622
623 /** @func
624 * @summary Synchronize Reading Multiple Files
625 * @desc reads an array of files into an object, whose keys are the
626 * input filenames, and values are the data read
627 * @arg {string[]} files - array of file names to read
628 * @arg {Object} [readOpts] - options to pass to fs.readFile()
629 */
630 syncReads: (files, readOpts) => new Promise((resolve,reject) => {
631 dlog(`syncing reads from ${files}`)
632 let count = 0
633 let results = {}
634 const read_cb = (fileName) => (err, data) => {
635 if (err)
636 reject(err)
637 else
638 results[fileName] = data
639 if (++count === files.length)
640 resolve(results)
641 }
642 if (readOpts == undefined)
643 readOpts = { encoding: 'utf8' }
644 files.forEach((file) =>
645 require('fs').readFile(file, readOpts, read_cb(file)))
646 })
647 }
648
649 module.exports = exports