Compare commits
	
		
			14 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 5a395a299a | |||
|  | eb36af8269 | ||
| 50c0449206 | |||
| d48707d265 | |||
| 60f85144a9 | |||
| 5dfe25ed95 | |||
| c9d6b46f0f | |||
| 0a67728239 | |||
| 9e06faa581 | |||
| 8be4d35698 | |||
| 531337bbc9 | |||
| e312d73e23 | |||
| 5380a519bd | |||
| bb018c538d | 
							
								
								
									
										9
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										9
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @ -43,3 +43,12 @@ jspm_packages | ||||
| 
 | ||||
| # Optional REPL history | ||||
| .node_repl_history | ||||
| 
 | ||||
| # Snapcraft | ||||
| /parts/ | ||||
| /prime/ | ||||
| /stage/ | ||||
| .snapcraft | ||||
| *.snap | ||||
| *.tar.bz2 | ||||
| 
 | ||||
|  | ||||
| @ -10,7 +10,7 @@ | ||||
| , "immed": true | ||||
| , "undef": true | ||||
| , "unused": true | ||||
| , "latedef": true | ||||
| , "latedef": "nofunc" | ||||
| , "curly": true | ||||
| , "trailing": true | ||||
| } | ||||
|  | ||||
							
								
								
									
										20
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										20
									
								
								README.md
									
									
									
									
									
								
							| @ -173,3 +173,23 @@ The user and group `telebit` should be created. | ||||
| # Linux | ||||
| sudo setcap 'cap_net_bind_service=+ep' $(which node) | ||||
| ``` | ||||
| 
 | ||||
| API | ||||
| === | ||||
| 
 | ||||
| The authentication method is abstract so that it can easily be implemented for various users and use cases. | ||||
| 
 | ||||
| ``` | ||||
| // bin/telebit-relay.js | ||||
| state.authenticate()                  // calls either state.extensions.authenticate or state.defaults.authenticate | ||||
|                                       // which, in turn, calls Server.onAuth() | ||||
| 
 | ||||
| state.extensions = require('../lib/extensions'); | ||||
| state.extensions.authenticate({ | ||||
|   state: state                        // lib/relay.js in-memory state | ||||
| , auth: 'xyz.abc.123'                 // arbitrary token, typically a JWT (default handler) | ||||
| }) | ||||
| 
 | ||||
| // lib/relay.js | ||||
| Server.onAuth(state, srv, rawAuth, validatedTokenData); | ||||
| ``` | ||||
|  | ||||
| @ -47,7 +47,13 @@ function applyConfig(config) { | ||||
|   } else { | ||||
|     state.Promise = require('bluebird'); | ||||
|   } | ||||
|   state.tlsOptions = {}; // TODO just close the sockets that would use this early? or use the admin servername
 | ||||
|   state.tlsOptions = { | ||||
|     // Handles disconnected devices
 | ||||
|     // TODO allow user to opt-in to wildcard hosting for a better error page?
 | ||||
|     SNICallback: function (servername, cb) { | ||||
|       return state.greenlock.tlsOptions.SNICallback(state.config.webminDomain || state.servernames[0], cb); | ||||
|     } | ||||
|   }; // TODO just close the sockets that would use this early? or use the admin servername
 | ||||
|   state.config = config; | ||||
|   state.servernames = config.servernames || []; | ||||
|   state.secret = state.config.secret; | ||||
|  | ||||
							
								
								
									
										35
									
								
								lib/ago-test.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								lib/ago-test.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,35 @@ | ||||
| 'use strict'; | ||||
| 
 | ||||
| var timeago = require('./ago.js').AGO; | ||||
| 
 | ||||
| function test() { | ||||
|   [ 1.5 * 1000 // a moment ago
 | ||||
|   , 4.5 * 1000 // moments ago
 | ||||
|   , 10  * 1000 // 10 seconds ago
 | ||||
|   , 59  * 1000 // a minute ago
 | ||||
|   , 60  * 1000 // a minute ago
 | ||||
|   , 61  * 1000 // a minute ago
 | ||||
|   , 119  * 1000 // a minute ago
 | ||||
|   , 120  * 1000 // 2 minutes ago
 | ||||
|   , 121 * 1000 // 2 minutes ago
 | ||||
|   , (60 * 60 * 1000) - 1000 // 59 minutes ago
 | ||||
|   , 1 * 60 * 60 * 1000 // an hour ago
 | ||||
|   , 1.5 * 60 * 60 * 1000 // an hour ago
 | ||||
|   , 2.5 * 60 * 60 * 1000 // 2 hours ago
 | ||||
|   , 1.5 * 24 * 60 * 60 * 1000 // a day ago
 | ||||
|   , 2.5 * 24 * 60 * 60 * 1000 // 2 days ago
 | ||||
|   , 7 * 24 * 60 * 60 * 1000 // a week ago
 | ||||
|   , 14 * 24 * 60 * 60 * 1000 // 2 weeks ago
 | ||||
|   , 27 * 24 * 60 * 60 * 1000 // 3 weeks ago
 | ||||
|   , 28 * 24 * 60 * 60 * 1000 // 4 weeks ago
 | ||||
|   , 29 * 24 * 60 * 60 * 1000 // 4 weeks ago
 | ||||
|   , 1.5 * 30 * 24 * 60 * 60 * 1000 // a month ago
 | ||||
|   , 2.5 * 30 * 24 * 60 * 60 * 1000 // 2 months ago
 | ||||
|   , (12 * 30 * 24 * 60 * 60 * 1000) + 1000 // 12 months ago
 | ||||
|   , 13 * 30 * 24 * 60 * 60 * 1000 // over a year ago
 | ||||
|   ].forEach(function (d) { | ||||
|     console.log(d, '=', timeago(d)); | ||||
|   }); | ||||
| } | ||||
| 
 | ||||
| test(); | ||||
							
								
								
									
										50
									
								
								lib/ago.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										50
									
								
								lib/ago.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,50 @@ | ||||
| ;(function (exports) { | ||||
| 'use strict'; | ||||
| 
 | ||||
| exports.AGO = function timeago(ms) { | ||||
|   var ago = Math.floor(ms / 1000); | ||||
|   var part = 0; | ||||
| 
 | ||||
|   if (ago < 2) { return "a moment ago"; } | ||||
|   if (ago < 5) { return "moments ago"; } | ||||
|   if (ago < 60) { return ago + " seconds ago"; } | ||||
| 
 | ||||
|   if (ago < 120) { return "a minute ago"; } | ||||
|   if (ago < 3600) { | ||||
|     while (ago >= 60) { ago -= 60; part += 1; } | ||||
|     return part + " minutes ago"; | ||||
|   } | ||||
| 
 | ||||
|   if (ago < 7200) { return "an hour ago"; } | ||||
|   if (ago < 86400) { | ||||
|     while (ago >= 3600) { ago -= 3600; part += 1; } | ||||
|     return part + " hours ago"; | ||||
|   } | ||||
| 
 | ||||
|   if (ago < 172800) { return "a day ago"; } | ||||
|   if (ago < 604800) { | ||||
|     while (ago >= 172800) { ago -= 172800; part += 1; } | ||||
|     return part + " days ago"; | ||||
|   } | ||||
| 
 | ||||
|   if (ago < 1209600) { return "a week ago"; } | ||||
|   if (ago < 2592000) { | ||||
|     while (ago >= 604800) { ago -= 604800; part += 1; } | ||||
|     return part + " weeks ago"; | ||||
|   } | ||||
| 
 | ||||
|   if (ago < 5184000) { return "a month ago"; } | ||||
|   if (ago < 31536001) { | ||||
|     while (ago >= 2592000) { ago -= 2592000; part += 1; } | ||||
|     return part + " months ago"; | ||||
|   } | ||||
| 
 | ||||
|   if (ago < 315360000) { // 10 years
 | ||||
|     return "more than year ago"; | ||||
|   } | ||||
| 
 | ||||
|   // TODO never
 | ||||
|   return ""; | ||||
| }; | ||||
| 
 | ||||
| }('undefined' !== typeof module ? module.exports : window)); | ||||
| @ -1,45 +1,138 @@ | ||||
| 'use strict'; | ||||
| 
 | ||||
| var Devices = module.exports; | ||||
| Devices.add = function (store, servername, newDevice) { | ||||
|   var devices = store[servername] || []; | ||||
|   devices.push(newDevice); | ||||
|   store[servername] = devices; | ||||
| // TODO enumerate store's keys and device's keys for documentation
 | ||||
| Devices.addPort = function (store, serverport, newDevice) { | ||||
|   // TODO make special
 | ||||
|   return Devices.add(store, serverport, newDevice, true); | ||||
| }; | ||||
| Devices.add = function (store, servername, newDevice, isPort) { | ||||
|   if (isPort) { | ||||
|     if (!store._ports) { store._ports = {}; } | ||||
|   } | ||||
| 
 | ||||
|   // add domain (also handles ports at the moment)
 | ||||
|   if (!store._domains) { store._domains = {}; } | ||||
|   if (!store._domains[servername]) { store._domains[servername] = []; } | ||||
|   store._domains[servername].push(newDevice); | ||||
|   Devices.touch(store, servername); | ||||
| 
 | ||||
|   // add device
 | ||||
|   // TODO only use a device id 
 | ||||
|   var devId = newDevice.id || servername; | ||||
|   if (!newDevice.__servername) { | ||||
|     newDevice.__servername = servername; | ||||
|   } | ||||
|   if (!store._devices) { store._devices = {}; } | ||||
|   if (!store._devices[devId]) { | ||||
|     store._devices[devId] = newDevice; | ||||
|     if (!store._devices[devId].domainsMap) { store._devices[devId].domainsMap = {}; } | ||||
|     if (!store._devices[devId].domainsMap[servername]) { store._devices[devId].domainsMap[servername] = true; } | ||||
|   } | ||||
| }; | ||||
| Devices.alias = function (store, servername, alias) { | ||||
|   if (!store._domains[servername]) { | ||||
|     store._domains[servername] = []; | ||||
|   } | ||||
|   if (!store._domains[servername]._primary) { | ||||
|     store._domains[servername]._primary = servername; | ||||
|   } | ||||
|   if (!store._domains[servername].aliases) { | ||||
|     store._domains[servername].aliases = {}; | ||||
|   } | ||||
|   store._domains[alias] = store._domains[servername]; | ||||
|   store._domains[servername].aliases[alias] = true; | ||||
| }; | ||||
| Devices.remove = function (store, servername, device) { | ||||
|   var devices = store[servername] || []; | ||||
|   // Check if this domain has an active device
 | ||||
|   var devices = store._domains[servername] || []; | ||||
|   var index = devices.indexOf(device); | ||||
| 
 | ||||
|   if (index < 0) { | ||||
|     console.warn('attempted to remove non-present device', device.deviceId, 'from', servername); | ||||
|     return null; | ||||
|   } | ||||
| 
 | ||||
|   // unlink this domain from this device
 | ||||
|   var domainsMap = store._devices[devices[index].id || servername].domainsMap; | ||||
|   delete domainsMap[servername]; | ||||
|   /* | ||||
|   // remove device if no domains remain
 | ||||
|   // nevermind, a device can hang around in limbo for a bit
 | ||||
|   if (!Object.keys(domains).length) { | ||||
|     delete store._devices[devices[index].id || servername]; | ||||
|   } | ||||
|   */ | ||||
| 
 | ||||
|   // unlink this device from this domain
 | ||||
|   return devices.splice(index, 1)[0]; | ||||
| }; | ||||
| Devices.close = function (store, device) { | ||||
|   var dev = store._devices[device.id || device.__servername]; | ||||
|   // because we're actually using names rather than  don't have reliable deviceIds yet
 | ||||
|   if (!dev) { | ||||
|     Object.keys(store._devices).some(function (key) { | ||||
|       if (store._devices[key].socketId === device.socketId) { | ||||
|         // TODO double check that all domains are removed
 | ||||
|         delete store._devices[key]; | ||||
|         return true; | ||||
|       } | ||||
|     }); | ||||
|   } | ||||
| }; | ||||
| Devices.bySocket = function (store, socketId) { | ||||
|   var dev; | ||||
|   Object.keys(store._devices).some(function (k) { | ||||
|     if (store._devices[k].socketId === socketId) { | ||||
|       dev = store._devices[k]; | ||||
|       return dev; | ||||
|     } | ||||
|   }); | ||||
|   return dev; | ||||
| }; | ||||
| Devices.list = function (store, servername) { | ||||
|   if (store[servername] && store[servername].length) { | ||||
|     return store[servername]; | ||||
|   console.log('[dontkeepme] servername', servername); | ||||
|   // efficient lookup first
 | ||||
|   if (store._domains[servername] && store._domains[servername].length) { | ||||
|     // aliases have ._primary which is the name of the original
 | ||||
|     return store._domains[servername]._primary && store._domains[store._domains[servername]._primary] || store._domains[servername]; | ||||
|   } | ||||
|   // There wasn't an exact match so check any of the wildcard domains, sorted longest
 | ||||
|   // first so the one with the biggest natural match with be found first.
 | ||||
|   var deviceList = []; | ||||
|   Object.keys(store).filter(function (pattern) { | ||||
|     return pattern[0] === '*' && store[pattern].length; | ||||
|   Object.keys(store._domains).filter(function (pattern) { | ||||
|     return pattern[0] === '*' && store._domains[pattern].length; | ||||
|   }).sort(function (a, b) { | ||||
|     return b.length - a.length; | ||||
|   }).some(function (pattern) { | ||||
|     var subPiece = pattern.slice(1); | ||||
|     if (subPiece === servername.slice(-subPiece.length)) { | ||||
|       console.log('"'+servername+'" matches "'+pattern+'"'); | ||||
|       deviceList = store[pattern]; | ||||
|       console.log('[Devices.list] "'+servername+'" matches "'+pattern+'"'); | ||||
|       deviceList = store._domains[pattern]; | ||||
| 
 | ||||
|       // Devices.alias(store, '*.example.com', 'sub.example.com'
 | ||||
|       // '*.example.com' retrieves a reference to 'example.com'
 | ||||
|       // and this reference then also referenced by 'sub.example.com'
 | ||||
|       // Hence this O(n) check is replaced with the O(1) check above
 | ||||
|       Devices.alias(store, pattern, servername); | ||||
|       return true; | ||||
|     } | ||||
|   }); | ||||
| 
 | ||||
|   return deviceList; | ||||
| }; | ||||
| /* | ||||
| Devices.active = function (store, id) { | ||||
|   var dev = store._devices[id]; | ||||
|   return !!dev; | ||||
| }; | ||||
| */ | ||||
| Devices.exist = function (store, servername) { | ||||
|   return !!(Devices.list(store, servername).length); | ||||
|   if (Devices.list(store, servername).length) { | ||||
|     Devices.touch(store, servername); | ||||
|     return true; | ||||
|   } | ||||
|   return false; | ||||
| }; | ||||
| Devices.next = function (store, servername) { | ||||
|   var devices = Devices.list(store, servername); | ||||
| @ -51,5 +144,20 @@ Devices.next = function (store, servername) { | ||||
|   device = devices[devices._index || 0]; | ||||
|   devices._index = (devices._index || 0) + 1; | ||||
| 
 | ||||
|   if (device) { Devices.touch(store, servername); } | ||||
|   return device; | ||||
| }; | ||||
| Devices.touchDevice = function (store, device) { | ||||
|   // TODO use device.id (which will be pubkey thumbprint) and store._devices[id].domainsMap
 | ||||
|   Object.keys(device.domainsMap).forEach(function (servername) { | ||||
|     Devices.touch(store, servername); | ||||
|   }); | ||||
| }; | ||||
| Devices.touch = function (store, servername) { | ||||
|   if (!store._recency) { store._recency = {}; } | ||||
|   store._recency[servername] = Date.now(); | ||||
| }; | ||||
| Devices.lastSeen = function (store, servername) { | ||||
|   if (!store._recency) { store._recency = {}; } | ||||
|   return store._recency[servername] || 0; | ||||
| }; | ||||
|  | ||||
| @ -10,7 +10,7 @@ function noSniCallback(tag) { | ||||
|     var err = new Error("[noSniCallback] no handler set for '" + tag + "':'" + servername + "'"); | ||||
|     console.error(err.message); | ||||
|     cb(new Error(err)); | ||||
|   } | ||||
|   }; | ||||
| } | ||||
| 
 | ||||
| module.exports.create = function (state) { | ||||
| @ -72,20 +72,11 @@ module.exports.create = function (state) { | ||||
|   state.tlsInvalidSniServer.on('tlsClientError', function () { | ||||
|     console.error('tlsClientError InvalidSniServer'); | ||||
|   }); | ||||
|   state.httpsInvalid = function (servername, socket) { | ||||
|     // none of these methods work:
 | ||||
|     // httpsServer.emit('connection', socket);  // this didn't work
 | ||||
|     // tlsServer.emit('connection', socket);    // this didn't work either
 | ||||
|     //console.log('chunkLen', firstChunk.byteLength);
 | ||||
| 
 | ||||
|     console.log('[httpsInvalid] servername', servername); | ||||
|     //state.tlsInvalidSniServer.emit('connection', wrapSocket(socket));
 | ||||
|     var tlsInvalidSniServer = tls.createServer(state.tlsOptions, function (tlsSocket) { | ||||
|       console.log('[tlsInvalid] tls connection'); | ||||
|       // things get a little messed up here
 | ||||
|       var httpInvalidSniServer = http.createServer(function (req, res) { | ||||
|         if (!servername) { | ||||
|   state.createHttpInvalid = function (opts) { | ||||
|     return http.createServer(function (req, res) { | ||||
|       if (!opts.servername) { | ||||
|         res.statusCode = 422; | ||||
|         res.setHeader('Content-Type', 'text/plain; charset=utf-8'); | ||||
|         res.end( | ||||
|           "3. An inexplicable temporal shift of the quantum realm... that makes me feel uncomfortable.\n\n" | ||||
|         + "[ERROR] No SNI header was sent. I can only think of two possible explanations for this:\n" | ||||
| @ -95,15 +86,30 @@ module.exports.create = function (state) { | ||||
|         return; | ||||
|       } | ||||
| 
 | ||||
|       // TODO use req.headers.host instead of servername (since domain fronting is disabled anyway)
 | ||||
|       res.statusCode = 502; | ||||
|       res.setHeader('Content-Type', 'text/html; charset=utf-8'); | ||||
|       res.end( | ||||
|           "You came in hot looking for '" + servername + "' and, granted, the IP address for that domain" | ||||
|         + " must be pointing here (or else how could you be here?), nevertheless either it's not registered" | ||||
|         + " in the internal system at all (which Seth says isn't even a thing) or there is no device" | ||||
|         + " connected on the south side of the network which has informed me that it's ready to have traffic" | ||||
|         + " for that domain forwarded to it (sorry I didn't check that deeply to determine which).\n\n" | ||||
|         + "Either way, you're doing strange things that make me feel uncomfortable... Please don't touch me there any more."); | ||||
|         "<h1>Oops!</h1>" | ||||
|       + "<p>It looks like '" + encodeURIComponent(opts.servername) + "' isn't connected right now.</p>" | ||||
|       + "<p><small>Last seen: " + opts.ago + "</small></p>" | ||||
|       + "<p><small>Error: 502 Bad Gateway</small></p>" | ||||
|       ); | ||||
|     }); | ||||
|       httpInvalidSniServer.emit('connection', tlsSocket); | ||||
|   }; | ||||
|   state.httpsInvalid = function (opts, socket) { | ||||
|     // none of these methods work:
 | ||||
|     // httpsServer.emit('connection', socket);  // this didn't work
 | ||||
|     // tlsServer.emit('connection', socket);    // this didn't work either
 | ||||
|     //console.log('chunkLen', firstChunk.byteLength);
 | ||||
| 
 | ||||
|     console.log('[httpsInvalid] servername', opts.servername); | ||||
|     //state.tlsInvalidSniServer.emit('connection', wrapSocket(socket));
 | ||||
|     var tlsInvalidSniServer = tls.createServer(state.tlsOptions, function (tlsSocket) { | ||||
|       console.log('[tlsInvalid] tls connection'); | ||||
|       // We create an entire http server object because it's difficult to figure out
 | ||||
|       // how to access the original tlsSocket to get the servername
 | ||||
|       state.createHttpInvalid(opts).emit('connection', tlsSocket); | ||||
|     }); | ||||
|     tlsInvalidSniServer.on('tlsClientError', function () { | ||||
|       console.error('tlsClientError InvalidSniServer httpsInvalid'); | ||||
| @ -136,11 +142,22 @@ module.exports.create = function (state) { | ||||
|     console.log('[Admin] custom or null tlsOptions for SNICallback'); | ||||
|     tunnelAdminTlsOpts.SNICallback = tunnelAdminTlsOpts.SNICallback || noSniCallback('admin'); | ||||
|   } | ||||
|   var MPROXY = Buffer.from("MPROXY"); | ||||
|   state.tlsTunnelServer = tls.createServer(tunnelAdminTlsOpts, function (tlsSocket) { | ||||
|     if (state.debug) { console.log('[Admin] new tls-terminated connection'); } | ||||
|     tlsSocket.once('readable', function () { | ||||
|       var firstChunk = tlsSocket.read(); | ||||
|       tlsSocket.unshift(firstChunk); | ||||
| 
 | ||||
|       if (0 === MPROXY.compare(firstChunk.slice(0, 4))) { | ||||
|         tlsSocket.end("MPROXY isn't supported yet"); | ||||
|         return; | ||||
|       } | ||||
| 
 | ||||
|       // things get a little messed up here
 | ||||
|       (state.httpTunnelServer || state.httpServer).emit('connection', tlsSocket); | ||||
|     }); | ||||
|   }); | ||||
|   state.tlsTunnelServer.on('tlsClientError', function () { | ||||
|     console.error('tlsClientError TunnelServer client error'); | ||||
|   }); | ||||
|  | ||||
| @ -16,7 +16,13 @@ module.exports = function pipeWs(servername, service, srv, conn, serviceport) { | ||||
|   function sendWs(data, serviceOverride) { | ||||
|     if (srv.ws && (!conn.tunnelClosing || serviceOverride)) { | ||||
|       try { | ||||
|         srv.ws.send(Packer.pack(browserAddr, data, serviceOverride), { binary: true }); | ||||
|         if (data && !Buffer.isBuffer(data)) { | ||||
|           data = Buffer.from(JSON.stringify(data)); | ||||
|         } | ||||
|         srv.ws.send(Packer.packHeader(browserAddr, data, serviceOverride), { binary: true }); | ||||
|         if (data) { | ||||
|           srv.ws.send(data, { binary: true }); | ||||
|         } | ||||
|         // If we can't send data over the websocket as fast as this connection can send it to us
 | ||||
|         // (or there are a lot of connections trying to send over the same websocket) then we
 | ||||
|         // need to pause the connection for a little. We pause all connections if any are paused
 | ||||
| @ -39,6 +45,10 @@ module.exports = function pipeWs(servername, service, srv, conn, serviceport) { | ||||
|   conn.serviceport = serviceport; | ||||
|   conn.service = service; | ||||
| 
 | ||||
|   // send peek at data too?
 | ||||
|   srv.ws.send(Packer.packHeader(browserAddr, null, 'connection'), { binary: true }); | ||||
| 
 | ||||
|   // TODO convert to read stream?
 | ||||
|   conn.on('data', function (chunk) { | ||||
|     //if (state.debug) { console.log('[pipeWs] client', cid, ' => srv', rid, chunk.byteLength, 'bytes'); }
 | ||||
|     sendWs(chunk); | ||||
|  | ||||
							
								
								
									
										531
									
								
								lib/relay.js
									
									
									
									
									
								
							
							
						
						
									
										531
									
								
								lib/relay.js
									
									
									
									
									
								
							| @ -1,535 +1,12 @@ | ||||
| 'use strict'; | ||||
| 
 | ||||
| var url = require('url'); | ||||
| var PromiseA = require('bluebird'); | ||||
| var sni = require('sni'); | ||||
| var Packer = require('proxy-packer'); | ||||
| var PortServers = {}; | ||||
| 
 | ||||
| function timeoutPromise(duration) { | ||||
|   return new PromiseA(function (resolve) { | ||||
|     setTimeout(resolve, duration); | ||||
|   }); | ||||
| } | ||||
| 
 | ||||
| var Devices = require('./device-tracker'); | ||||
| var pipeWs = require('./pipe-ws.js'); | ||||
| 
 | ||||
| var Server = { | ||||
|   _initCommandHandlers: function (state, srv) { | ||||
|     var commandHandlers = { | ||||
|       add_token: function addToken(newAuth) { | ||||
|         return Server.addToken(state, srv, newAuth); | ||||
|       } | ||||
|     , delete_token: function (token) { | ||||
|         return state.Promise.resolve(function () { | ||||
|           var err; | ||||
| 
 | ||||
|           if (token !== '*') { | ||||
|             err = Server.removeToken(state, srv, token); | ||||
|             if (err) { return state.Promise.reject(err); } | ||||
|           } | ||||
| 
 | ||||
|           Object.keys(srv.grants).some(function (jwtoken) { | ||||
|             err = Server.removeToken(state, srv, jwtoken); | ||||
|             return err; | ||||
|           }); | ||||
|           if (err) { return state.Promise.reject(err); } | ||||
| 
 | ||||
|           return null; | ||||
|         }); | ||||
|       } | ||||
|     }; | ||||
|     commandHandlers.auth = commandHandlers.add_token; | ||||
|     commandHandlers.authn = commandHandlers.add_token; | ||||
|     commandHandlers.authz = commandHandlers.add_token; | ||||
|     srv._commandHandlers = commandHandlers; | ||||
|   } | ||||
| , _initPackerHandlers: function (state, srv) { | ||||
|     var packerHandlers = { | ||||
|       oncontrol: function (tun) { | ||||
|         var cmd; | ||||
|         try { | ||||
|           cmd = JSON.parse(tun.data.toString()); | ||||
|         } catch (e) {} | ||||
|         if (!Array.isArray(cmd) || typeof cmd[0] !== 'number') { | ||||
|           var msg = 'received bad command "' + tun.data.toString() + '"'; | ||||
|           console.warn(msg, 'from websocket', srv.socketId); | ||||
|           Server.sendTunnelMsg(srv, null, [0, {message: msg, code: 'E_BAD_COMMAND'}], 'control'); | ||||
|           return; | ||||
|         } | ||||
| 
 | ||||
|         if (cmd[0] < 0) { | ||||
|           // We only ever send one command and we send it once, so we just hard coded the ID as 1.
 | ||||
|           if (cmd[0] === -1) { | ||||
|             if (cmd[1]) { | ||||
|               console.warn('received error response to hello from', srv.socketId, cmd[1]); | ||||
|             } | ||||
|           } | ||||
|           else { | ||||
|             console.warn('received response to unknown command', cmd, 'from', srv.socketId); | ||||
|           } | ||||
|           return; | ||||
|         } | ||||
| 
 | ||||
|         if (cmd[0] === 0) { | ||||
|           console.warn('received dis-associated error from', srv.socketId, cmd[1]); | ||||
|           return; | ||||
|         } | ||||
| 
 | ||||
|         function onSuccess() { | ||||
|           Server.sendTunnelMsg(srv, null, [-cmd[0], null], 'control'); | ||||
|         } | ||||
|         function onError(err) { | ||||
|           Server.sendTunnelMsg(srv, null, [-cmd[0], err], 'control'); | ||||
|         } | ||||
| 
 | ||||
|         if (!srv._commandHandlers[cmd[1]]) { | ||||
|           onError({ message: 'unknown command "'+cmd[1]+'"', code: 'E_UNKNOWN_COMMAND' }); | ||||
|           return; | ||||
|         } | ||||
| 
 | ||||
|         console.log('command:', cmd[1], cmd.slice(2)); | ||||
|         return srv._commandHandlers[cmd[1]].apply(null, cmd.slice(2)).then(onSuccess, onError); | ||||
|       } | ||||
| 
 | ||||
|     , onmessage: function (tun) { | ||||
|         var cid = Packer.addrToId(tun); | ||||
|         if (state.debug) { console.log("remote '" + Server.logName(state, srv) + "' has data for '" + cid + "'", tun.data.byteLength); } | ||||
| 
 | ||||
|         var browserConn = Server.getBrowserConn(state, srv, cid); | ||||
|         if (!browserConn) { | ||||
|           Server.sendTunnelMsg(srv, tun, {message: 'no matching connection', code: 'E_NO_CONN'}, 'error'); | ||||
|           return; | ||||
|         } | ||||
| 
 | ||||
|         browserConn.write(tun.data); | ||||
|         // tunnelRead is how many bytes we've read from the tunnel, and written to the browser.
 | ||||
|         browserConn.tunnelRead = (browserConn.tunnelRead || 0) + tun.data.byteLength; | ||||
|         // If we have more than 1MB buffered data we need to tell the other side to slow down.
 | ||||
|         // Once we've finished sending what we have we can tell the other side to keep going.
 | ||||
|         // If we've already sent the 'pause' message though don't send it again, because we're
 | ||||
|         // probably just dealing with data queued before our message got to them.
 | ||||
|         if (!browserConn.remotePaused && browserConn.bufferSize > 1024*1024) { | ||||
|           Server.sendTunnelMsg(srv, tun, browserConn.tunnelRead, 'pause'); | ||||
|           browserConn.remotePaused = true; | ||||
| 
 | ||||
|           browserConn.once('drain', function () { | ||||
|             Server.sendTunnelMsg(srv, tun, browserConn.tunnelRead, 'resume'); | ||||
|             browserConn.remotePaused = false; | ||||
|           }); | ||||
|         } | ||||
|       } | ||||
| 
 | ||||
|     , onpause: function (tun) { | ||||
|         var cid = Packer.addrToId(tun); | ||||
|         console.log('[TunnelPause]', cid); | ||||
|         var browserConn = Server.getBrowserConn(state, srv, cid); | ||||
|         if (browserConn) { | ||||
|           browserConn.manualPause = true; | ||||
|           browserConn.pause(); | ||||
|         } else { | ||||
|           Server.sendTunnelMsg(srv, tun, {message: 'no matching connection', code: 'E_NO_CONN'}, 'error'); | ||||
|         } | ||||
|       } | ||||
| 
 | ||||
|     , onresume: function (tun) { | ||||
|         var cid = Packer.addrToId(tun); | ||||
|         console.log('[TunnelResume]', cid); | ||||
|         var browserConn = Server.getBrowserConn(state, srv, cid); | ||||
|         if (browserConn) { | ||||
|           browserConn.manualPause = false; | ||||
|           browserConn.resume(); | ||||
|         } else { | ||||
|           Server.sendTunnelMsg(srv, tun, {message: 'no matching connection', code: 'E_NO_CONN'}, 'error'); | ||||
|         } | ||||
|       } | ||||
| 
 | ||||
|     , onend: function (tun) { | ||||
|         var cid = Packer.addrToId(tun); | ||||
|         console.log('[TunnelEnd]', cid); | ||||
|         Server.closeBrowserConn(state, srv, cid); | ||||
|       } | ||||
|     , onerror: function (tun) { | ||||
|         var cid = Packer.addrToId(tun); | ||||
|         console.warn('[TunnelError]', cid, tun.message); | ||||
|         Server.closeBrowserConn(state, srv, cid); | ||||
|       } | ||||
|     }; | ||||
|     srv._packerHandlers = packerHandlers; | ||||
|     srv.unpacker = Packer.create(srv._packerHandlers); | ||||
|   } | ||||
| , _initSocketHandlers: function (state, srv) { | ||||
|     function refreshTimeout() { | ||||
|       srv.lastActivity = Date.now(); | ||||
|     } | ||||
| 
 | ||||
|     function checkTimeout() { | ||||
|       // Determine how long the connection has been "silent", ie no activity.
 | ||||
|       var silent = Date.now() - srv.lastActivity; | ||||
| 
 | ||||
|       // If we have had activity within the last activityTimeout then all we need to do is
 | ||||
|       // call this function again at the soonest time when the connection could be timed out.
 | ||||
|       if (silent < state.activityTimeout) { | ||||
|         srv.timeoutId = setTimeout(checkTimeout, state.activityTimeout - silent); | ||||
|       } | ||||
| 
 | ||||
|       // Otherwise we check to see if the pong has also timed out, and if not we send a ping
 | ||||
|       // and call this function again when the pong will have timed out.
 | ||||
|       else if (silent < state.activityTimeout + state.pongTimeout) { | ||||
|         if (state.debug) { console.log('pinging', Server.logName(state, srv)); } | ||||
|         try { | ||||
|           srv.ws.ping(); | ||||
|         } catch (err) { | ||||
|           console.warn('failed to ping home cloud', Server.logName(state, srv)); | ||||
|         } | ||||
|         srv.timeoutId = setTimeout(checkTimeout, state.pongTimeout); | ||||
|       } | ||||
| 
 | ||||
|       // Last case means the ping we sent before didn't get a response soon enough, so we
 | ||||
|       // need to close the websocket connection.
 | ||||
|       else { | ||||
|         console.warn('home cloud', Server.logName(state, srv), 'connection timed out'); | ||||
|         srv.ws.close(1013, 'connection timeout'); | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     function forwardMessage(chunk) { | ||||
|       refreshTimeout(); | ||||
|       if (state.debug) { console.log('[ws] device => client : demultiplexing message ', chunk.byteLength, 'bytes'); } | ||||
|       //console.log(chunk.toString());
 | ||||
|       srv.unpacker.fns.addChunk(chunk); | ||||
|     } | ||||
| 
 | ||||
|     function hangup() { | ||||
|       clearTimeout(srv.timeoutId); | ||||
|       console.log('[ws] device hangup', Server.logName(state, srv), 'connection closing'); | ||||
|       Object.keys(srv.grants).forEach(function (jwtoken) { | ||||
|         Server.removeToken(state, srv, jwtoken); | ||||
|       }); | ||||
|       srv.ws.terminate(); | ||||
|     } | ||||
| 
 | ||||
|     srv.lastActivity = Date.now(); | ||||
|     srv.timeoutId = null; | ||||
|     srv.timeoutId = setTimeout(checkTimeout, state.activityTimeout); | ||||
| 
 | ||||
|     // Note that our websocket library automatically handles pong responses on ping requests
 | ||||
|     // before it even emits the event.
 | ||||
|     srv.ws.on('ping', refreshTimeout); | ||||
|     srv.ws.on('pong', refreshTimeout); | ||||
|     srv.ws.on('message', forwardMessage); | ||||
|     srv.ws.on('close', hangup); | ||||
|     srv.ws.on('error', hangup); | ||||
|   } | ||||
| , init: function init(state, srv) { | ||||
|     Server._initCommandHandlers(state, srv); | ||||
|     Server._initPackerHandlers(state, srv); | ||||
|     Server._initSocketHandlers(state, srv); | ||||
| 
 | ||||
|     // Status Code '1' for Status 'hello'
 | ||||
|     Server.sendTunnelMsg(srv, null, [1, 'hello', [srv.unpacker._version], Object.keys(srv._commandHandlers)], 'control'); | ||||
|   } | ||||
| , sendTunnelMsg: function sendTunnelMsg(srv, addr, data, service) { | ||||
|     srv.ws.send(Packer.pack(addr, data, service), {binary: true}); | ||||
|   } | ||||
| , logName: function logName(state, srv) { | ||||
|     var result = Object.keys(srv.grants).map(function (jwtoken) { | ||||
|       return srv.grants[jwtoken].currentDesc; | ||||
|     }).join(';'); | ||||
| 
 | ||||
|     return result || srv.socketId; | ||||
|   } | ||||
| , onAuth: function onAuth(state, srv, newAuth, grant) { | ||||
|     console.log('\n[relay.js] onAuth'); | ||||
|     console.log(newAuth); | ||||
|     console.log(grant); | ||||
|     //var stringauth;
 | ||||
|     var err; | ||||
|     if (!grant || 'object' !== typeof grant) { | ||||
|       console.log('[relay.js] invalid token', grant); | ||||
|       err = new Error("invalid access token"); | ||||
|       err.code = "E_INVALID_TOKEN"; | ||||
|       return state.Promise.reject(err); | ||||
|     } | ||||
| 
 | ||||
|     if ('string' !== typeof newAuth) { | ||||
|       newAuth = JSON.stringify(newAuth); | ||||
|     } | ||||
| 
 | ||||
|     console.log('check for upgrade token'); | ||||
|     if (grant.jwt && newAuth !== grant.jwt) { | ||||
|       console.log('new token to send back'); | ||||
|       // Access Token
 | ||||
|       Server.sendTunnelMsg( | ||||
|         srv | ||||
|       , null | ||||
|       , [ 3 | ||||
|         , 'access_token' | ||||
|         , { jwt: grant.jwt } | ||||
|         ] | ||||
|       , 'control' | ||||
|       ); | ||||
|       // these aren't needed internally once they're sent
 | ||||
|       grant.jwt = null; | ||||
|     } | ||||
| 
 | ||||
|     /* | ||||
|     if (!Array.isArray(grant.domains) || !grant.domains.length) { | ||||
|       err = new Error("invalid domains array"); | ||||
|       err.code = "E_INVALID_NAME"; | ||||
|       return state.Promise.reject(err); | ||||
|     } | ||||
|     */ | ||||
|     if (grant.domains.some(function (name) { return typeof name !== 'string'; })) { | ||||
|       console.log('bad domain names'); | ||||
|       err = new Error("invalid domain name(s)"); | ||||
|       err.code = "E_INVALID_NAME"; | ||||
|       return state.Promise.reject(err); | ||||
|     } | ||||
| 
 | ||||
|     console.log('strolling through pleasantries'); | ||||
|     // Add the custom properties we need to manage this remote, then add it to all the relevant
 | ||||
|     // domains and the list of all this websocket's grants.
 | ||||
|     grant.domains.forEach(function (domainname) { | ||||
|       console.log('add', domainname, 'to device lists'); | ||||
|       srv.domainsMap[domainname] = true; | ||||
|       Devices.add(state.deviceLists, domainname, srv); | ||||
|     }); | ||||
|     srv.domains = Object.keys(srv.domainsMap); | ||||
|     srv.currentDesc = (grant.device && (grant.device.id || grant.device.hostname)) || srv.domains.join(','); | ||||
|     grant.currentDesc = (grant.device && (grant.device.id || grant.device.hostname)) || grant.domains.join(','); | ||||
|     grant.srv = srv; | ||||
|     //grant.ws = srv.ws;
 | ||||
|     //grant.upgradeReq = srv.upgradeReq;
 | ||||
|     grant.clients = {}; | ||||
| 
 | ||||
|     if (!grant.ports) { grant.ports = []; } | ||||
| 
 | ||||
|     function openPort(serviceport) { | ||||
|       function tcpListener(conn) { | ||||
|         Server.onDynTcpConn(state, srv, srv.portsMap[serviceport], conn); | ||||
|       } | ||||
|       serviceport = parseInt(serviceport, 10) || 0; | ||||
|       if (!serviceport) { | ||||
|         // TODO error message about bad port
 | ||||
|         return; | ||||
|       } | ||||
|       if (PortServers[serviceport]) { | ||||
|         console.log('reuse', serviceport, 'for this connection'); | ||||
|         //grant.ports = [];
 | ||||
|         srv.portsMap[serviceport] = PortServers[serviceport]; | ||||
|         srv.portsMap[serviceport].on('connection', tcpListener); | ||||
|         srv.portsMap[serviceport].tcpListener = tcpListener; | ||||
|         Devices.add(state.deviceLists, serviceport, srv); | ||||
|       } else { | ||||
|         try { | ||||
|           console.log('use new', serviceport, 'for this connection'); | ||||
|           srv.portsMap[serviceport] = PortServers[serviceport] = require('net').createServer(tcpListener); | ||||
|           srv.portsMap[serviceport].tcpListener = tcpListener; | ||||
|           srv.portsMap[serviceport].listen(serviceport, function () { | ||||
|             console.info('[DynTcpConn] Port', serviceport, 'now open for', grant.currentDesc); | ||||
|             Devices.add(state.deviceLists, serviceport, srv); | ||||
|           }); | ||||
|           srv.portsMap[serviceport].on('error', function (e) { | ||||
|             // TODO try again with random port
 | ||||
|             console.error("Server Error assigning a dynamic port to a new connection:", e); | ||||
|           }); | ||||
|         } catch(e) { | ||||
|           // what a wonderful problem it will be the day that this bug needs to be fixed
 | ||||
|           // (i.e. there are enough users to run out of ports)
 | ||||
|           console.error("Error assigning a dynamic port to a new connection:", e); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|     grant.ports.forEach(openPort); | ||||
| 
 | ||||
|     srv.grants[newAuth] = grant; | ||||
|     console.info("[ws] authorized", srv.socketId, "for", grant.currentDesc); | ||||
| 
 | ||||
|     console.log('notify of grants', grant.domains, grant.ports); | ||||
|     Server.sendTunnelMsg( | ||||
|       srv | ||||
|     , null | ||||
|     , [ 2 | ||||
|       , 'grant' | ||||
|       , [ ['ssh+https', grant.domains[0], 443 ] | ||||
|         , ['ssh', 'ssh.' + state.config.sharedDomain, grant.ports ] | ||||
|         , ['tcp', 'tcp.' + state.config.sharedDomain, grant.ports ] | ||||
|         , ['https', grant.domains[0] ] | ||||
|         ] | ||||
|       ] | ||||
|     , 'control' | ||||
|     ); | ||||
|     return null; | ||||
|   } | ||||
| , onDynTcpConn: function onDynTcpConn(state, srv, server, conn) { | ||||
|     var serviceport = server.address().port; | ||||
|     console.log('[DynTcpConn] new connection on', serviceport); | ||||
|     var nextDevice = Devices.next(state.deviceLists, serviceport); | ||||
| 
 | ||||
|     if (!nextDevice) { | ||||
|       conn.write("[Sanity Error] I've got a blank space baby, but nowhere to write your name."); | ||||
|       conn.end(); | ||||
|       try { | ||||
|         server.close(); | ||||
|       } catch(e) { | ||||
|         console.error("[DynTcpConn] failed to close server:", e); | ||||
|       } | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     conn.once('data', function (firstChunk) { | ||||
|       if (state.debug) { console.log("[DynTcp]", serviceport, "examining firstChunk from", Packer.socketToId(conn)); } | ||||
|       conn.pause(); | ||||
|       //conn.unshift(firstChunk);
 | ||||
|       conn._handle.onread(firstChunk.length, firstChunk); | ||||
| 
 | ||||
|       var servername; | ||||
|       var hostname; | ||||
|       var str; | ||||
|       var m; | ||||
| 
 | ||||
|       if (22 === firstChunk[0]) { | ||||
|         servername = (sni(firstChunk)||'').toLowerCase(); | ||||
|       } else if (firstChunk[0] > 32 && firstChunk[0] < 127) { | ||||
|         str = firstChunk.toString(); | ||||
|         m = str.match(/(?:^|[\r\n])Host: ([^\r\n]+)[\r\n]*/im); | ||||
|         hostname = (m && m[1].toLowerCase() || '').split(':')[0]; | ||||
|       } | ||||
| 
 | ||||
|       if (servername || hostname) { | ||||
|         if (servername) { | ||||
|           conn.write("TLS with sni is allowed only on standard ports. If you've registered '" + servername + "' use port 443."); | ||||
|         } else { | ||||
|           conn.write("HTTP with Host headers is not allowed on dynamic ports. If you've registered '" + hostname + "' use port 80."); | ||||
|         } | ||||
|         conn.end(); | ||||
|         return; | ||||
|       } | ||||
| 
 | ||||
|       // pipeWs(servername, servicename, srv, client, serviceport)
 | ||||
|       // remote.clients is managed as part of the piping process
 | ||||
|       if (state.debug) { console.log("[DynTcp]", serviceport, "piping to srv (via loadbal)"); } | ||||
|       pipeWs(null, 'tcp', nextDevice, conn, serviceport); | ||||
| 
 | ||||
|       process.nextTick(function () { conn.resume(); }); | ||||
|     }); | ||||
|   } | ||||
| , addToken: function addToken(state, srv, newAuth) { | ||||
|     console.log("addToken", newAuth); | ||||
|     if (srv.grants[newAuth]) { | ||||
|       console.log("addToken - duplicate"); | ||||
|       // return { message: "token sent multiple times", code: "E_TOKEN_REPEAT" };
 | ||||
|       return state.Promise.resolve(null); | ||||
|     } | ||||
| 
 | ||||
|     return state.authenticate({ auth: newAuth }).then(function (authnToken) { | ||||
| 
 | ||||
|       console.log('\n[relay.js] newAuth'); | ||||
|       console.log(newAuth); | ||||
| 
 | ||||
|       console.log('\n[relay.js] authnToken'); | ||||
|       console.log(authnToken); | ||||
| 
 | ||||
|       if (authnToken.id) { | ||||
|         state.srvs[authnToken.id] = state.srvs[authnToken.id] || {}; | ||||
|         state.srvs[authnToken.id].updateAuth = function (validToken) { | ||||
|           return Server.onAuth(state, srv, newAuth, validToken); | ||||
|         }; | ||||
|       } | ||||
| 
 | ||||
|       // will return rejection if necessary
 | ||||
|       return state.srvs[authnToken.id].updateAuth(authnToken); | ||||
|     }); | ||||
|   } | ||||
| , removeToken: function removeToken(state, srv, jwtoken) { | ||||
|     var grant = srv.grants[jwtoken]; | ||||
|     if (!grant) { | ||||
|       return { message: 'specified token not present', code: 'E_INVALID_TOKEN'}; | ||||
|     } | ||||
| 
 | ||||
|     // Prevent any more browser connections for this grant being sent to this srv,
 | ||||
|     // and any existing connections from trying to send more data across the connection.
 | ||||
|     grant.domains.forEach(function (domainname) { | ||||
|       Devices.remove(state.deviceLists, domainname, srv); | ||||
|     }); | ||||
|     grant.ports.forEach(function (portnumber) { | ||||
|       Devices.remove(state.deviceLists, portnumber, srv); | ||||
|       if (!srv.portsMap[portnumber]) { return; } | ||||
|       try { | ||||
|         srv.portsMap[portnumber].close(function () { | ||||
|           console.log("[DynTcpConn] closing server for ", portnumber); | ||||
|           delete srv.portsMap[portnumber]; | ||||
|           delete PortServers[portnumber]; | ||||
|         }); | ||||
|       } catch(e) { /*ignore*/ } | ||||
|     }); | ||||
| 
 | ||||
|     // Close all of the existing browser connections associated with this websocket connection.
 | ||||
|     Object.keys(grant.clients).forEach(function (cid) { | ||||
|       Server.closeBrowserConn(state, srv, cid); | ||||
|     }); | ||||
|     delete srv.grants[jwtoken]; | ||||
|     console.log("[ws] removed token '" + grant.currentDesc + "' from", srv.socketId); | ||||
|     return null; | ||||
|   } | ||||
| , getBrowserConn: function getBrowserConn(state, srv, cid) { | ||||
|     return srv.clients[cid]; | ||||
|   } | ||||
| , closeBrowserConn: function closeBrowserConn(state, srv, cid) { | ||||
|     if (!srv.clients[cid]) { | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     PromiseA.resolve().then(function () { | ||||
|       var conn = srv.clients[cid]; | ||||
|       conn.tunnelClosing = true; | ||||
|       conn.end(); | ||||
| 
 | ||||
|       // If no data is buffered for writing then we don't need to wait for it to drain.
 | ||||
|       if (!conn.bufferSize) { | ||||
|         return timeoutPromise(500); | ||||
|       } | ||||
|       // Otherwise we want the connection to be able to finish, but we also want to impose
 | ||||
|       // a time limit for it to drain, since it shouldn't have more than 1MB buffered.
 | ||||
|       return new PromiseA(function (resolve) { | ||||
|         var timeoutId = setTimeout(resolve, 60*1000); | ||||
|         conn.once('drain', function () { | ||||
|           clearTimeout(timeoutId); | ||||
|           setTimeout(resolve, 500); | ||||
|         }); | ||||
|       }); | ||||
|     }).then(function () { | ||||
|       if (srv.clients[cid]) { | ||||
|         console.warn(cid, 'browser connection still present after calling `end`'); | ||||
|         srv.clients[cid].destroy(); | ||||
|         return timeoutPromise(500); | ||||
|       } | ||||
|     }).then(function () { | ||||
|       if (srv.clients[cid]) { | ||||
|         console.error(cid, 'browser connection still present after calling `destroy`'); | ||||
|         delete srv.clients[cid]; | ||||
|       } | ||||
|     }).catch(function (err) { | ||||
|       console.warn('failed to close browser connection', cid, err); | ||||
|     }); | ||||
|   } | ||||
| , parseAuth: function parseAuth(state, srv) { | ||||
|     var authn = (srv.upgradeReq.headers.authorization||'').split(/\s+/); | ||||
|     if (authn[0] && 'basic' === authn[0].toLowerCase()) { | ||||
|       try { | ||||
|         authn = new Buffer(authn[1], 'base64').toString('ascii').split(':'); | ||||
|         return authn[1]; | ||||
|       } catch (err) { } | ||||
|     } | ||||
|     return url.parse(srv.upgradeReq.url, true).query.access_token; | ||||
|   } | ||||
| }; | ||||
| var Server = require('./server.js'); | ||||
| 
 | ||||
| module.exports.store = { Devices: Devices }; | ||||
| module.exports.create = function (state) { | ||||
|   state.deviceLists = {}; | ||||
|   state.deviceLists = { _domains: {}, _devices: {} }; | ||||
|   state.deviceCallbacks = {}; | ||||
|   state.srvs = {}; | ||||
| 
 | ||||
| @ -556,12 +33,16 @@ module.exports.create = function (state) { | ||||
|     var initToken; | ||||
|     srv.ws = _ws; | ||||
|     srv.upgradeReq = _upgradeReq; | ||||
|     // TODO use device's ECDSA thumbprint as device id
 | ||||
|     srv.id  = null; | ||||
|     srv.socketId = Packer.socketToId(srv.upgradeReq.socket); | ||||
|     srv.grants = {}; | ||||
|     srv.clients = {}; | ||||
|     srv.domainsMap = {}; | ||||
|     srv.portsMap = {}; | ||||
|     srv.pausedConns = []; | ||||
|     srv.domains = []; | ||||
|     srv.ports = []; | ||||
| 
 | ||||
|     if (state.debug) { console.log('[ws] connection', srv.socketId); } | ||||
| 
 | ||||
|  | ||||
							
								
								
									
										580
									
								
								lib/server.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										580
									
								
								lib/server.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,580 @@ | ||||
| 'use strict'; | ||||
| 
 | ||||
| var url = require('url'); | ||||
| var sni = require('sni'); | ||||
| var Packer = require('proxy-packer'); | ||||
| var PromiseA; | ||||
| try { | ||||
|   PromiseA = require('bluebird'); | ||||
| } catch(e) { | ||||
|   PromiseA = global.Promise; | ||||
| } | ||||
| 
 | ||||
| function timeoutPromise(duration) { | ||||
|   return new PromiseA(function (resolve) { | ||||
|     setTimeout(resolve, duration); | ||||
|   }); | ||||
| } | ||||
| var Devices = require('./device-tracker'); | ||||
| var pipeWs = require('./pipe-ws.js'); | ||||
| var PortServers = {}; | ||||
| var Server = { | ||||
|   _initCommandHandlers: function (state, srv) { | ||||
|     var commandHandlers = { | ||||
|       add_token: function addToken(newAuth) { | ||||
|         return Server.addToken(state, srv, newAuth); | ||||
|       } | ||||
|     , delete_token: function (token) { | ||||
|         return state.Promise.resolve(function () { | ||||
|           var err; | ||||
| 
 | ||||
|           if (token !== '*') { | ||||
|             err = Server.removeToken(state, srv, token); | ||||
|             if (err) { return state.Promise.reject(err); } | ||||
|           } | ||||
| 
 | ||||
|           Object.keys(srv.grants).some(function (jwtoken) { | ||||
|             err = Server.removeToken(state, srv, jwtoken); | ||||
|             return err; | ||||
|           }); | ||||
|           if (err) { return state.Promise.reject(err); } | ||||
| 
 | ||||
|           return null; | ||||
|         }); | ||||
|       } | ||||
|     }; | ||||
|     commandHandlers.auth = commandHandlers.add_token; | ||||
|     commandHandlers.authn = commandHandlers.add_token; | ||||
|     commandHandlers.authz = commandHandlers.add_token; | ||||
|     srv._commandHandlers = commandHandlers; | ||||
|   } | ||||
| , _initPackerHandlers: function (state, srv) { | ||||
|     var packerHandlers = { | ||||
|       oncontrol: function (tun) { | ||||
|         var cmd; | ||||
|         try { | ||||
|           cmd = JSON.parse(tun.data.toString()); | ||||
|         } catch (e) {} | ||||
|         if (!Array.isArray(cmd) || typeof cmd[0] !== 'number') { | ||||
|           var msg = 'received bad command "' + tun.data.toString() + '"'; | ||||
|           console.warn(msg, 'from websocket', srv.socketId); | ||||
|           Server.sendTunnelMsg(srv, null, [0, {message: msg, code: 'E_BAD_COMMAND'}], 'control'); | ||||
|           return; | ||||
|         } | ||||
| 
 | ||||
|         if (cmd[0] < 0) { | ||||
|           // We only ever send one command and we send it once, so we just hard coded the ID as 1.
 | ||||
|           if (cmd[0] === -1) { | ||||
|             if (cmd[1]) { | ||||
|               console.warn('received error response to hello from', srv.socketId, cmd[1]); | ||||
|             } | ||||
|           } | ||||
|           else { | ||||
|             console.warn('received response to unknown command', cmd, 'from', srv.socketId); | ||||
|           } | ||||
|           return; | ||||
|         } | ||||
| 
 | ||||
|         if (cmd[0] === 0) { | ||||
|           console.warn('received dis-associated error from', srv.socketId, cmd[1]); | ||||
|           return; | ||||
|         } | ||||
| 
 | ||||
|         function onSuccess() { | ||||
|           Server.sendTunnelMsg(srv, null, [-cmd[0], null], 'control'); | ||||
|         } | ||||
|         function onError(err) { | ||||
|           Server.sendTunnelMsg(srv, null, [-cmd[0], err], 'control'); | ||||
|         } | ||||
| 
 | ||||
|         if (!srv._commandHandlers[cmd[1]]) { | ||||
|           onError({ message: 'unknown command "'+cmd[1]+'"', code: 'E_UNKNOWN_COMMAND' }); | ||||
|           return; | ||||
|         } | ||||
| 
 | ||||
|         console.log('command:', cmd[1], cmd.slice(2)); | ||||
|         return srv._commandHandlers[cmd[1]].apply(null, cmd.slice(2)).then(onSuccess, onError); | ||||
|       } | ||||
| 
 | ||||
|     , onconnection: function (/*tun*/) { | ||||
|         // I don't think this event can happen since this relay
 | ||||
|         // is acting the part of the client, but just in case...
 | ||||
|         // (in fact it should probably be explicitly disallowed)
 | ||||
|         console.error("[SANITY FAIL] reverse connection start"); | ||||
|       } | ||||
| 
 | ||||
|     , onmessage: function (tun) { | ||||
|         var cid = Packer.addrToId(tun); | ||||
|         if (state.debug) { console.log("remote '" + Server.logName(state, srv) + "' has data for '" + cid + "'", tun.data.byteLength); } | ||||
| 
 | ||||
|         var browserConn = Server.getBrowserConn(state, srv, cid); | ||||
|         if (!browserConn) { | ||||
|           Server.sendTunnelMsg(srv, tun, {message: 'no matching connection', code: 'E_NO_CONN'}, 'error'); | ||||
|           return; | ||||
|         } | ||||
| 
 | ||||
|         browserConn.write(tun.data); | ||||
|         // tunnelRead is how many bytes we've read from the tunnel, and written to the browser.
 | ||||
|         browserConn.tunnelRead = (browserConn.tunnelRead || 0) + tun.data.byteLength; | ||||
|         // If we have more than 1MB buffered data we need to tell the other side to slow down.
 | ||||
|         // Once we've finished sending what we have we can tell the other side to keep going.
 | ||||
|         // If we've already sent the 'pause' message though don't send it again, because we're
 | ||||
|         // probably just dealing with data queued before our message got to them.
 | ||||
|         if (!browserConn.remotePaused && browserConn.bufferSize > 1024*1024) { | ||||
|           Server.sendTunnelMsg(srv, tun, browserConn.tunnelRead, 'pause'); | ||||
|           browserConn.remotePaused = true; | ||||
| 
 | ||||
|           browserConn.once('drain', function () { | ||||
|             Server.sendTunnelMsg(srv, tun, browserConn.tunnelRead, 'resume'); | ||||
|             browserConn.remotePaused = false; | ||||
|           }); | ||||
|         } | ||||
|       } | ||||
| 
 | ||||
|     , onpause: function (tun) { | ||||
|         var cid = Packer.addrToId(tun); | ||||
|         console.log('[TunnelPause]', cid); | ||||
|         var browserConn = Server.getBrowserConn(state, srv, cid); | ||||
|         if (browserConn) { | ||||
|           browserConn.manualPause = true; | ||||
|           browserConn.pause(); | ||||
|         } else { | ||||
|           Server.sendTunnelMsg(srv, tun, {message: 'no matching connection', code: 'E_NO_CONN'}, 'error'); | ||||
|         } | ||||
|       } | ||||
| 
 | ||||
|     , onresume: function (tun) { | ||||
|         var cid = Packer.addrToId(tun); | ||||
|         console.log('[TunnelResume]', cid); | ||||
|         var browserConn = Server.getBrowserConn(state, srv, cid); | ||||
|         if (browserConn) { | ||||
|           browserConn.manualPause = false; | ||||
|           browserConn.resume(); | ||||
|         } else { | ||||
|           Server.sendTunnelMsg(srv, tun, {message: 'no matching connection', code: 'E_NO_CONN'}, 'error'); | ||||
|         } | ||||
|       } | ||||
| 
 | ||||
|     , onend: function (tun) { | ||||
|         var cid = Packer.addrToId(tun); | ||||
|         console.log('[TunnelEnd]', cid); | ||||
|         Server.closeBrowserConn(state, srv, cid); | ||||
|       } | ||||
|     , onerror: function (tun) { | ||||
|         var cid = Packer.addrToId(tun); | ||||
|         console.warn('[TunnelError]', cid, tun.message); | ||||
|         Server.closeBrowserConn(state, srv, cid); | ||||
|       } | ||||
|     }; | ||||
|     srv._packerHandlers = packerHandlers; | ||||
|     srv.unpacker = Packer.create(srv._packerHandlers); | ||||
|   } | ||||
| , _initSocketHandlers: function (state, srv) { | ||||
|     function refreshTimeout() { | ||||
|       srv.lastActivity = Date.now(); | ||||
|       Devices.touchDevice(state.deviceLists, srv); | ||||
|     } | ||||
| 
 | ||||
|     function checkTimeout() { | ||||
|       // Determine how long the connection has been "silent", ie no activity.
 | ||||
|       var silent = Date.now() - srv.lastActivity; | ||||
| 
 | ||||
|       // If we have had activity within the last activityTimeout then all we need to do is
 | ||||
|       // call this function again at the soonest time when the connection could be timed out.
 | ||||
|       if (silent < state.activityTimeout) { | ||||
|         srv.timeoutId = setTimeout(checkTimeout, state.activityTimeout - silent); | ||||
|       } | ||||
| 
 | ||||
|       // Otherwise we check to see if the pong has also timed out, and if not we send a ping
 | ||||
|       // and call this function again when the pong will have timed out.
 | ||||
|       else if (silent < state.activityTimeout + state.pongTimeout) { | ||||
|         if (state.debug) { console.log('pinging', Server.logName(state, srv)); } | ||||
|         try { | ||||
|           srv.ws.ping(); | ||||
|         } catch (err) { | ||||
|           console.warn('failed to ping home cloud', Server.logName(state, srv)); | ||||
|         } | ||||
|         srv.timeoutId = setTimeout(checkTimeout, state.pongTimeout); | ||||
|       } | ||||
| 
 | ||||
|       // Last case means the ping we sent before didn't get a response soon enough, so we
 | ||||
|       // need to close the websocket connection.
 | ||||
|       else { | ||||
|         console.warn('home cloud', Server.logName(state, srv), 'connection timed out'); | ||||
|         srv.ws.close(1013, 'connection timeout'); | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     function forwardMessage(chunk) { | ||||
|       refreshTimeout(); | ||||
|       if (state.debug) { console.log('[ws] device => client : demultiplexing message ', chunk.byteLength, 'bytes'); } | ||||
|       //console.log(chunk.toString());
 | ||||
|       srv.unpacker.fns.addChunk(chunk); | ||||
|     } | ||||
| 
 | ||||
|     function hangup() { | ||||
|       clearTimeout(srv.timeoutId); | ||||
|       console.log('[ws] device hangup', Server.logName(state, srv), 'connection closing'); | ||||
|       // remove the allowed domains from the list (but leave the socket)
 | ||||
|       Object.keys(srv.grants).forEach(function (jwtoken) { | ||||
|         Server.removeToken(state, srv, jwtoken); | ||||
|       }); | ||||
|       srv.ws.terminate(); | ||||
|       // remove the socket from the list, period
 | ||||
|       Devices.close(state.deviceLists, srv); | ||||
|     } | ||||
| 
 | ||||
|     srv.lastActivity = Date.now(); | ||||
|     srv.timeoutId = null; | ||||
|     srv.timeoutId = setTimeout(checkTimeout, state.activityTimeout); | ||||
| 
 | ||||
|     // Note that our websocket library automatically handles pong responses on ping requests
 | ||||
|     // before it even emits the event.
 | ||||
|     srv.ws.on('ping', refreshTimeout); | ||||
|     srv.ws.on('pong', refreshTimeout); | ||||
|     srv.ws.on('message', forwardMessage); | ||||
|     srv.ws.on('close', hangup); | ||||
|     srv.ws.on('error', hangup); | ||||
|   } | ||||
| , init: function init(state, srv) { | ||||
|     Server._initCommandHandlers(state, srv); | ||||
|     Server._initPackerHandlers(state, srv); | ||||
|     Server._initSocketHandlers(state, srv); | ||||
| 
 | ||||
|     // Status Code '1' for Status 'hello'
 | ||||
|     Server.sendTunnelMsg(srv, null, [1, 'hello', [srv.unpacker._version], Object.keys(srv._commandHandlers)], 'control'); | ||||
|   } | ||||
| , sendTunnelMsg: function sendTunnelMsg(srv, addr, data, service) { | ||||
|     if (data && !Buffer.isBuffer()) { | ||||
|       data = Buffer.from(JSON.stringify(data)); | ||||
|     } | ||||
|     srv.ws.send(Packer.packHeader(addr, data, service), {binary: true}); | ||||
|     srv.ws.send(data, {binary: true}); | ||||
|   } | ||||
| , logName: function logName(state, srv) { | ||||
|     var result = Object.keys(srv.grants).map(function (jwtoken) { | ||||
|       return srv.grants[jwtoken].currentDesc; | ||||
|     }).join(';'); | ||||
| 
 | ||||
|     return result || srv.socketId; | ||||
|   } | ||||
| , onAuth: function onAuth(state, srv, rawAuth, grant) { | ||||
|     console.log('\n[relay.js] onAuth'); | ||||
|     console.log(rawAuth); | ||||
|     //console.log(grant);
 | ||||
|     //var stringauth;
 | ||||
|     var err; | ||||
|     if (!grant || 'object' !== typeof grant) { | ||||
|       console.log('[relay.js] invalid token', grant); | ||||
|       err = new Error("invalid access token"); | ||||
|       err.code = "E_INVALID_TOKEN"; | ||||
|       return state.Promise.reject(err); | ||||
|     } | ||||
| 
 | ||||
|     // deprecated (for json object on connect)
 | ||||
|     if ('string' !== typeof rawAuth) { | ||||
|       rawAuth = JSON.stringify(rawAuth); | ||||
|     } | ||||
| 
 | ||||
|     // TODO don't fire the onAuth event on non-authz updates
 | ||||
|     if (!grant.jwt && !(grant.domains||[]).length && !(grant.ports||[]).length) { | ||||
|       console.log("[onAuth] nothing to offer at all"); | ||||
|       return null; | ||||
|     } | ||||
| 
 | ||||
|     console.log('[onAuth] check for upgrade token'); | ||||
|     //console.log(grant);
 | ||||
|     if (grant.jwt) { | ||||
|       if (rawAuth !== grant.jwt) { | ||||
|         console.log('[onAuth] token is new'); | ||||
|       } | ||||
|       // TODO only send token when new
 | ||||
|       if (true) { | ||||
|         // Access Token
 | ||||
|         console.log('[onAuth] sending back token'); | ||||
|         Server.sendTunnelMsg( | ||||
|           srv | ||||
|         , null | ||||
|         , [ 3 | ||||
|           , 'access_token' | ||||
|           , { jwt: grant.jwt } | ||||
|           ] | ||||
|         , 'control' | ||||
|         ); | ||||
|         // these aren't needed internally once they're sent
 | ||||
|         grant.jwt = null; | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     /* | ||||
|     if (!Array.isArray(grant.domains) || !grant.domains.length) { | ||||
|       err = new Error("invalid domains array"); | ||||
|       err.code = "E_INVALID_NAME"; | ||||
|       return state.Promise.reject(err); | ||||
|     } | ||||
|     */ | ||||
|     if (grant.domains.some(function (name) { return typeof name !== 'string'; })) { | ||||
|       console.log('bad domain names'); | ||||
|       err = new Error("invalid domain name(s)"); | ||||
|       err.code = "E_INVALID_NAME"; | ||||
|       return state.Promise.reject(err); | ||||
|     } | ||||
| 
 | ||||
|     console.log('[onAuth] strolling through pleasantries'); | ||||
|     // Add the custom properties we need to manage this remote, then add it to all the relevant
 | ||||
|     // domains and the list of all this websocket's grants.
 | ||||
|     grant.domains.forEach(function (domainname) { | ||||
|       console.log('add', domainname, 'to device lists'); | ||||
|       srv.domainsMap[domainname] = true; | ||||
|       Devices.add(state.deviceLists, domainname, srv); | ||||
|       // TODO allow subs to go to individual devices
 | ||||
|       Devices.alias(state.deviceLists, domainname, '*.' + domainname); | ||||
|     }); | ||||
|     srv.domains = Object.keys(srv.domainsMap); | ||||
|     srv.currentDesc = (grant.device && (grant.device.id || grant.device.hostname)) || srv.domains.join(','); | ||||
|     grant.currentDesc = (grant.device && (grant.device.id || grant.device.hostname)) || grant.domains.join(','); | ||||
|     //grant.srv = srv;
 | ||||
|     //grant.ws = srv.ws;
 | ||||
|     //grant.upgradeReq = srv.upgradeReq;
 | ||||
|     grant.clients = {}; | ||||
| 
 | ||||
|     if (!grant.ports) { grant.ports = []; } | ||||
| 
 | ||||
|     function openPort(serviceport) { | ||||
|       function tcpListener(conn) { | ||||
|         Server.onDynTcpConn(state, srv, srv.portsMap[serviceport], conn); | ||||
|       } | ||||
|       serviceport = parseInt(serviceport, 10) || 0; | ||||
|       if (!serviceport) { | ||||
|         // TODO error message about bad port
 | ||||
|         return; | ||||
|       } | ||||
|       if (PortServers[serviceport]) { | ||||
|         console.log('reuse', serviceport, 'for this connection'); | ||||
|         //grant.ports = [];
 | ||||
|         srv.portsMap[serviceport] = PortServers[serviceport]; | ||||
|         srv.portsMap[serviceport].on('connection', tcpListener); | ||||
|         srv.portsMap[serviceport].tcpListener = tcpListener; | ||||
|         Devices.addPort(state.deviceLists, serviceport, srv); | ||||
|       } else { | ||||
|         try { | ||||
|           console.log('use new', serviceport, 'for this connection'); | ||||
|           srv.portsMap[serviceport] = PortServers[serviceport] = require('net').createServer(tcpListener); | ||||
|           srv.portsMap[serviceport].tcpListener = tcpListener; | ||||
|           srv.portsMap[serviceport].listen(serviceport, function () { | ||||
|             console.info('[DynTcpConn] Port', serviceport, 'now open for', grant.currentDesc); | ||||
|             Devices.addPort(state.deviceLists, serviceport, srv); | ||||
|           }); | ||||
|           srv.portsMap[serviceport].on('error', function (e) { | ||||
|             // TODO try again with random port
 | ||||
|             console.error("Server Error assigning a dynamic port to a new connection:", e); | ||||
|           }); | ||||
|         } catch(e) { | ||||
|           // what a wonderful problem it will be the day that this bug needs to be fixed
 | ||||
|           // (i.e. there are enough users to run out of ports)
 | ||||
|           console.error("Error assigning a dynamic port to a new connection:", e); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|     grant.ports.forEach(openPort); | ||||
| 
 | ||||
|     console.info("[ws] authorized", srv.socketId, "for", grant.currentDesc); | ||||
|     console.log('notify of grants', grant.domains, grant.ports); | ||||
|     srv.grants[rawAuth] = grant; | ||||
|     Server.sendTunnelMsg( | ||||
|       srv | ||||
|     , null | ||||
|     , [ 2 | ||||
|       , 'grant' | ||||
|       , [ ['ssh+https', grant.domains[0], 443 ] | ||||
|           // TODO the shared domain should be token specific
 | ||||
|         , ['ssh', 'ssh.' + state.config.sharedDomain, [grant.ports[0]] ] | ||||
|         , ['tcp', 'tcp.' + state.config.sharedDomain, [grant.ports[0]] ] | ||||
|         , ['https', grant.domains[0] ] | ||||
|         ] | ||||
|       ] | ||||
|     , 'control' | ||||
|     ); | ||||
|     return null; | ||||
|   } | ||||
| , onDynTcpConn: function onDynTcpConn(state, srv, server, conn) { | ||||
|     var serviceport = server.address().port; | ||||
|     console.log('[DynTcpConn] new connection on', serviceport); | ||||
|     var nextDevice = Devices.next(state.deviceLists, serviceport); | ||||
| 
 | ||||
|     if (!nextDevice) { | ||||
|       conn.write("[Sanity Error] I've got a blank space baby, but nowhere to write your name."); | ||||
|       conn.end(); | ||||
|       try { | ||||
|         server.close(); | ||||
|       } catch(e) { | ||||
|         console.error("[DynTcpConn] failed to close server:", e); | ||||
|       } | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     // When using raw TCP we're already paired to the client by port
 | ||||
|     // and we can begin connecting right away, but we'll wait just a sec
 | ||||
|     // to reject known bad connections
 | ||||
|     var sendConnection = setTimeout(function () { | ||||
|       conn.removeListener('data', peekFirstPacket); | ||||
|       console.log("[debug tcp conn] Connecting possible telnet client to device..."); | ||||
|       pipeWs(null, 'tcp', nextDevice, conn, serviceport); | ||||
|     }, 350); | ||||
|     function peekFirstPacket(firstChunk) { | ||||
|       clearTimeout(sendConnection); | ||||
|       if (state.debug) { console.log("[DynTcp]", serviceport, "examining firstChunk from", Packer.socketToId(conn)); } | ||||
|       conn.pause(); | ||||
|       //conn.unshift(firstChunk);
 | ||||
|       conn._handle.onread(firstChunk.length, firstChunk); | ||||
| 
 | ||||
|       var servername; | ||||
|       var hostname; | ||||
|       var str; | ||||
|       var m; | ||||
| 
 | ||||
|       if (22 === firstChunk[0]) { | ||||
|         servername = (sni(firstChunk)||'').toLowerCase(); | ||||
|       } else if (firstChunk[0] > 32 && firstChunk[0] < 127) { | ||||
|         str = firstChunk.toString(); | ||||
|         m = str.match(/(?:^|[\r\n])Host: ([^\r\n]+)[\r\n]*/im); | ||||
|         hostname = (m && m[1].toLowerCase() || '').split(':')[0]; | ||||
|       } | ||||
| 
 | ||||
|       if (servername || hostname) { | ||||
|         if (servername) { | ||||
|           conn.write("TLS with sni is allowed only on standard ports. If you've registered '" + servername + "' use port 443."); | ||||
|         } else { | ||||
|           conn.write("HTTP with Host headers is not allowed on dynamic ports. If you've registered '" + hostname + "' use port 80."); | ||||
|         } | ||||
|         conn.end(); | ||||
|         return; | ||||
|       } | ||||
| 
 | ||||
|       // pipeWs(servername, servicename, srv, client, serviceport)
 | ||||
|       // remote.clients is managed as part of the piping process
 | ||||
|       if (state.debug) { console.log("[DynTcp]", serviceport, "piping to srv (via loadbal)"); } | ||||
|       pipeWs(null, 'tcp', nextDevice, conn, serviceport); | ||||
| 
 | ||||
|       process.nextTick(function () { conn.resume(); }); | ||||
|     } | ||||
|     conn.once('data', peekFirstPacket); | ||||
|   } | ||||
| , addToken: function addToken(state, srv, rawAuth) { | ||||
|     console.log("[addToken]", rawAuth); | ||||
|     if (srv.grants[rawAuth]) { | ||||
|       console.log("addToken - duplicate"); | ||||
|       // return { message: "token sent multiple times", code: "E_TOKEN_REPEAT" };
 | ||||
|       return state.Promise.resolve(null); | ||||
|     } | ||||
| 
 | ||||
|     // [Extension] [Auth] This is where authentication is either handed off to
 | ||||
|     //                    an extension or the default authencitation handler.
 | ||||
|     return state.authenticate({ auth: rawAuth }).then(function (validatedTokenData) { | ||||
|       console.log('\n[relay.js] rawAuth'); | ||||
|       console.log(rawAuth); | ||||
| 
 | ||||
|       console.log('\n[relay.js] authnToken'); | ||||
|       console.log(validatedTokenData); | ||||
| 
 | ||||
|       // For tracking state between token exchanges
 | ||||
|       // and tacking on extra attributes (i.e. for extensions)
 | ||||
|       // TODO close on delete
 | ||||
|       if (!state.srvs[validatedTokenData.id]) { | ||||
|         state.srvs[validatedTokenData.id] = {}; | ||||
|       } | ||||
|       if (!state.srvs[validatedTokenData.id].updateAuth) { | ||||
|         // be sure to always pass latest srv since the connection may change
 | ||||
|         // and reuse the same token
 | ||||
|         state.srvs[validatedTokenData.id].updateAuth = function (srv, validatedTokenData) { | ||||
|           return Server.onAuth(state, srv, rawAuth, validatedTokenData); | ||||
|         }; | ||||
|       } | ||||
|       state.srvs[validatedTokenData.id].updateAuth(srv, validatedTokenData); | ||||
|     }); | ||||
|   } | ||||
| , removeToken: function removeToken(state, srv, jwtoken) { | ||||
|     var grant = srv.grants[jwtoken]; | ||||
|     if (!grant) { | ||||
|       return { message: 'specified token not present', code: 'E_INVALID_TOKEN'}; | ||||
|     } | ||||
| 
 | ||||
|     // Prevent any more browser connections for this grant being sent to this srv,
 | ||||
|     // and any existing connections from trying to send more data across the connection.
 | ||||
|     grant.domains.forEach(function (domainname) { | ||||
|       Devices.remove(state.deviceLists, domainname, srv); | ||||
|     }); | ||||
|     grant.ports.forEach(function (portnumber) { | ||||
|       Devices.remove(state.deviceLists, portnumber, srv); | ||||
|       if (!srv.portsMap[portnumber]) { return; } | ||||
|       try { | ||||
|         srv.portsMap[portnumber].close(function () { | ||||
|           console.log("[DynTcpConn] closing server for ", portnumber); | ||||
|           delete srv.portsMap[portnumber]; | ||||
|           delete PortServers[portnumber]; | ||||
|         }); | ||||
|       } catch(e) { /*ignore*/ } | ||||
|     }); | ||||
| 
 | ||||
|     // Close all of the existing browser connections associated with this websocket connection.
 | ||||
|     Object.keys(grant.clients).forEach(function (cid) { | ||||
|       Server.closeBrowserConn(state, srv, cid); | ||||
|     }); | ||||
|     delete srv.grants[jwtoken]; | ||||
|     console.log("[ws] removed token '" + grant.currentDesc + "' from", srv.socketId); | ||||
|     return null; | ||||
|   } | ||||
| , getBrowserConn: function getBrowserConn(state, srv, cid) { | ||||
|     return srv.clients[cid]; | ||||
|   } | ||||
| , closeBrowserConn: function closeBrowserConn(state, srv, cid) { | ||||
|     if (!srv.clients[cid]) { | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     PromiseA.resolve().then(function () { | ||||
|       var conn = srv.clients[cid]; | ||||
|       conn.tunnelClosing = true; | ||||
|       conn.end(); | ||||
| 
 | ||||
|       // If no data is buffered for writing then we don't need to wait for it to drain.
 | ||||
|       if (!conn.bufferSize) { | ||||
|         return timeoutPromise(500); | ||||
|       } | ||||
|       // Otherwise we want the connection to be able to finish, but we also want to impose
 | ||||
|       // a time limit for it to drain, since it shouldn't have more than 1MB buffered.
 | ||||
|       return new PromiseA(function (resolve) { | ||||
|         var timeoutId = setTimeout(resolve, 60*1000); | ||||
|         conn.once('drain', function () { | ||||
|           clearTimeout(timeoutId); | ||||
|           setTimeout(resolve, 500); | ||||
|         }); | ||||
|       }); | ||||
|     }).then(function () { | ||||
|       if (srv.clients[cid]) { | ||||
|         console.warn(cid, 'browser connection still present after calling `end`'); | ||||
|         srv.clients[cid].destroy(); | ||||
|         return timeoutPromise(500); | ||||
|       } | ||||
|     }).then(function () { | ||||
|       if (srv.clients[cid]) { | ||||
|         console.error(cid, 'browser connection still present after calling `destroy`'); | ||||
|         delete srv.clients[cid]; | ||||
|       } | ||||
|     }).catch(function (err) { | ||||
|       console.warn('failed to close browser connection', cid, err); | ||||
|     }); | ||||
|   } | ||||
| , parseAuth: function parseAuth(state, srv) { | ||||
|     var authn = (srv.upgradeReq.headers.authorization||'').split(/\s+/); | ||||
|     if (authn[0] && 'basic' === authn[0].toLowerCase()) { | ||||
|       try { | ||||
|         authn = new Buffer(authn[1], 'base64').toString('ascii').split(':'); | ||||
|         return authn[1]; | ||||
|       } catch (err) { } | ||||
|     } | ||||
|     return url.parse(srv.upgradeReq.url, true).query.access_token; | ||||
|   } | ||||
| }; | ||||
| 
 | ||||
| module.exports = Server; | ||||
| @ -2,6 +2,16 @@ | ||||
| 
 | ||||
| var sni = require('sni'); | ||||
| var pipeWs = require('./pipe-ws.js'); | ||||
| var ago = require('./ago.js').AGO; | ||||
| var up = Date.now(); | ||||
| 
 | ||||
| function fromUptime(ms) { | ||||
|   if (ms) { | ||||
|     return ago(Date.now() - ms); | ||||
|   } else { | ||||
|     return "Not seen since relay restarted, " + ago(Date.now() - up); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| module.exports.createTcpConnectionHandler = function (state) { | ||||
|   var Devices = state.Devices; | ||||
| @ -18,13 +28,26 @@ module.exports.createTcpConnectionHandler = function (state) { | ||||
|     //});
 | ||||
| 
 | ||||
|     //return;
 | ||||
|     conn.once('data', function (firstChunk) { | ||||
|     //conn.once('data', function (firstChunk) {
 | ||||
|     //});
 | ||||
|     conn.once('readable', function () { | ||||
|       var firstChunk = conn.read(); | ||||
|       var service = 'tcp'; | ||||
|       var servername; | ||||
|       var str; | ||||
|       var m; | ||||
| 
 | ||||
|       conn.pause(); | ||||
|       if (!firstChunk) { | ||||
|         try { | ||||
|           conn.end(); | ||||
|         } catch(e) { | ||||
|           console.error("[lib/unwrap-tls.js] Error:", e); | ||||
|           conn.destroy(); | ||||
|         } | ||||
|         return; | ||||
|       } | ||||
| 
 | ||||
|       //conn.pause();
 | ||||
|       conn.unshift(firstChunk); | ||||
| 
 | ||||
|       // BUG XXX: this assumes that the packet won't be chunked smaller
 | ||||
| @ -35,40 +58,97 @@ module.exports.createTcpConnectionHandler = function (state) { | ||||
| 
 | ||||
|       // defer after return (instead of being in many places)
 | ||||
|       function deferData(fn) { | ||||
|         if (fn) { | ||||
|         if ('httpsInvalid' === fn) { | ||||
|           state[fn]({ | ||||
|             servername: servername | ||||
|           , ago: fromUptime(Devices.lastSeen(state.deviceLists, servername)) | ||||
|           }, conn); | ||||
|         } else if (fn) { | ||||
|           state[fn](servername, conn); | ||||
|         } else { | ||||
|           console.error("[SANITY ERROR] '" + fn + "' doesn't have a state handler"); | ||||
|         } | ||||
|         /* | ||||
|         process.nextTick(function () { | ||||
|           conn.resume(); | ||||
|         }); | ||||
|         */ | ||||
|       } | ||||
| 
 | ||||
|       function tryTls() { | ||||
|         var vhost; | ||||
|       var httpOutcomes = { | ||||
|         missingServername: function () { | ||||
|           console.log("[debug] [http] missing servername"); | ||||
|           // TODO use a more specific error page
 | ||||
|           deferData('handleInsecureHttp'); | ||||
|         } | ||||
|       , requiresSetup: function () { | ||||
|           console.log("[debug] [http] requires setup"); | ||||
|           // TODO Insecure connections for setup will not work on secure domains (i.e. .app)
 | ||||
|           state.httpSetupServer.emit('connection', conn); | ||||
|         } | ||||
|       , isInternal: function () { | ||||
|           console.log("[debug] [http] is known internally (admin)"); | ||||
|           if (/well-known/.test(str)) { | ||||
|             deferData('handleHttp'); | ||||
|           } else { | ||||
|             deferData('handleInsecureHttp'); | ||||
|           } | ||||
|         } | ||||
|       , isVhost: function () { | ||||
|           console.log("[debug] [http] is vhost (normal server)"); | ||||
|           if (/well-known/.test(str)) { | ||||
|             deferData('handleHttp'); | ||||
|           } else { | ||||
|             deferData('handleInsecureHttp'); | ||||
|           } | ||||
|         } | ||||
|       , assumeExternal: function () { | ||||
|           console.log("[debug] [http] assume external"); | ||||
|           var service = 'http'; | ||||
| 
 | ||||
|         if (!state.servernames.length) { | ||||
|           console.info("[Setup] https => admin => setup => (needs bogus tls certs to start?)"); | ||||
|           deferData('httpsSetupServer'); | ||||
|           if (!Devices.exist(state.deviceLists, servername)) { | ||||
|             // It would be better to just re-read the host header rather
 | ||||
|             // than creating a whole server object, but this is a "rare"
 | ||||
|             // case and I'm feeling lazy right now.
 | ||||
|             console.log("[debug] [http] no device connected"); | ||||
|             state.createHttpInvalid({ | ||||
|               servername: servername | ||||
|             , ago: fromUptime(Devices.lastSeen(state.deviceLists, servername)) | ||||
|             }).emit('connection', conn); | ||||
|             return; | ||||
|           } | ||||
| 
 | ||||
|         if (-1 !== state.servernames.indexOf(servername)) { | ||||
|           if (state.debug) { console.log("[Admin]", servername); } | ||||
|           deferData('httpsTunnel'); | ||||
|           // TODO make https redirect configurable on a per-domain basis
 | ||||
|           // /^\/\.well-known\/acme-challenge\//.test(str)
 | ||||
|           if (/well-known/.test(str)) { | ||||
|             // HTTP
 | ||||
|             console.log("[debug] [http] passthru"); | ||||
|             pipeWs(servername, service, Devices.next(state.deviceLists, servername), conn, serviceport); | ||||
|             return; | ||||
|           } else { | ||||
|             console.log("[debug] [http] redirect to https"); | ||||
|             deferData('handleInsecureHttp'); | ||||
|           } | ||||
| 
 | ||||
|         if (state.config.nowww && /^www\./i.test(servername)) { | ||||
|           console.log("TODO: use www bare redirect"); | ||||
|         } | ||||
| 
 | ||||
|         if (!servername) { | ||||
|       }; | ||||
|       var tlsOutcomes = { | ||||
|         missingServername: function () { | ||||
|           if (state.debug) { console.log("No SNI was given, so there's nothing we can do here"); } | ||||
|           deferData('httpsInvalid'); | ||||
|           return; | ||||
|         } | ||||
| 
 | ||||
|         function run() { | ||||
|       , requiresSetup: function () { | ||||
|           console.info("[Setup] https => admin => setup => (needs bogus tls certs to start?)"); | ||||
|           deferData('httpsSetupServer'); | ||||
|         } | ||||
|       , isInternal: function () { | ||||
|           if (state.debug) { console.log("[Admin]", servername); } | ||||
|           deferData('httpsTunnel'); | ||||
|         } | ||||
|       , isVhost: function (vhost) { | ||||
|           if (state.debug) { console.log("[tcp] [vhost]", state.config.vhost, "=>", vhost); } | ||||
|           deferData('httpsVhost'); | ||||
|         } | ||||
|       , assumeExternal: function () { | ||||
|          var nextDevice = Devices.next(state.deviceLists, servername); | ||||
|           if (!nextDevice) { | ||||
|             if (state.debug) { console.log("No devices match the given servername"); } | ||||
| @ -77,27 +157,33 @@ module.exports.createTcpConnectionHandler = function (state) { | ||||
|           } | ||||
| 
 | ||||
|           if (state.debug) { console.log("pipeWs(servername, service, deviceLists['" + servername + "'], socket)"); } | ||||
|           deferData(); | ||||
|           pipeWs(servername, service, nextDevice, conn, serviceport); | ||||
|         } | ||||
|       }; | ||||
| 
 | ||||
|       function handleConnection(outcomes) { | ||||
|         var vhost; | ||||
| 
 | ||||
|         // No routing information available
 | ||||
|         if (!servername) { outcomes.missingServername(); return; } | ||||
|         // Server needs to be set up
 | ||||
|         if (!state.servernames.length) { outcomes.requiresSetup(); return; } | ||||
|         // This is one of the admin domains
 | ||||
|         if (-1 !== state.servernames.indexOf(servername)) { outcomes.isInternal(); return; } | ||||
| 
 | ||||
|         // TODO don't run an fs check if we already know this is working elsewhere
 | ||||
|         //if (!state.validHosts) { state.validHosts = {}; }
 | ||||
|         if (state.config.vhost) { | ||||
|           vhost = state.config.vhost.replace(/:hostname/, (servername||'reallydoesntexist')); | ||||
|           if (state.debug) { console.log("[tcp] [vhost]", state.config.vhost, "=>", vhost); } | ||||
|           //state.httpsVhost(servername, conn);
 | ||||
|           //return;
 | ||||
|           vhost = state.config.vhost.replace(/:hostname/, servername); | ||||
|           require('fs').readdir(vhost, function (err, nodes) { | ||||
|             if (state.debug && err) { console.log("VHOST error", err); } | ||||
|             if (err || !nodes) { run(); return; } | ||||
|             //if (nodes) { deferData('httpsVhost'); return; }
 | ||||
|             deferData('httpsVhost'); | ||||
|             if (err || !nodes) { outcomes.assumeExternal(); return; } | ||||
|             outcomes.isVhost(vhost); | ||||
|           }); | ||||
|           return; | ||||
|         } | ||||
| 
 | ||||
|         run(); | ||||
|         outcomes.assumeExternal(); | ||||
|       } | ||||
| 
 | ||||
|       // https://github.com/mscdex/httpolyglot/issues/3#issuecomment-173680155
 | ||||
| @ -106,40 +192,19 @@ module.exports.createTcpConnectionHandler = function (state) { | ||||
|         service = 'https'; | ||||
|         servername = (sni(firstChunk)||'').toLowerCase().trim(); | ||||
|         if (state.debug) { console.log("[tcp] tls hello from '" + servername + "'"); } | ||||
|         tryTls(); | ||||
|         handleConnection(tlsOutcomes); | ||||
|         return; | ||||
|       } | ||||
| 
 | ||||
|       if (firstChunk[0] > 32 && firstChunk[0] < 127) { | ||||
|         // (probably) HTTP
 | ||||
|         str = firstChunk.toString(); | ||||
|         m = str.match(/(?:^|[\r\n])Host: ([^\r\n]+)[\r\n]*/im); | ||||
|         servername = (m && m[1].toLowerCase() || '').split(':')[0]; | ||||
|         if (state.debug) { console.log("[tcp] http hostname '" + servername + "'"); } | ||||
| 
 | ||||
|         if (/HTTP\//i.test(str)) { | ||||
|           if (!state.servernames.length) { | ||||
|             console.info("[tcp] No admin servername. Entering setup mode."); | ||||
|             deferData(); | ||||
|             state.httpSetupServer.emit('connection', conn); | ||||
|             return; | ||||
|           } | ||||
| 
 | ||||
|           service = 'http'; | ||||
|           // TODO make https redirect configurable
 | ||||
|           // /^\/\.well-known\/acme-challenge\//.test(str)
 | ||||
|           if (/well-known/.test(str)) { | ||||
|             // HTTP
 | ||||
|             if (Devices.exist(state.deviceLists, servername)) { | ||||
|               deferData(); | ||||
|               pipeWs(servername, service, Devices.next(state.deviceLists, servername), conn, serviceport); | ||||
|               return; | ||||
|             } | ||||
|             deferData('handleHttp'); | ||||
|             return; | ||||
|           } | ||||
| 
 | ||||
|           // redirect to https
 | ||||
|           deferData('handleInsecureHttp'); | ||||
|           handleConnection(httpOutcomes); | ||||
|           return; | ||||
|         } | ||||
|       } | ||||
|  | ||||
| @ -1,6 +1,6 @@ | ||||
| { | ||||
|   "name": "telebit-relay", | ||||
|   "version": "0.13.1", | ||||
|   "version": "0.20.0", | ||||
|   "description": "Friends don't let friends localhost. Expose your bits with a secure connection even from behind NAT, Firewalls, in a box, with a fox, on a train or in a plane... or a Raspberry Pi in your closet. An attempt to create a better localtunnel.me server, a more open ngrok. Uses Automated HTTPS (Free SSL) via ServerName Indication (SNI). Can also tunnel tls and plain tcp.", | ||||
|   "main": "lib/relay.js", | ||||
|   "bin": { | ||||
| @ -43,8 +43,8 @@ | ||||
|     "greenlock": "^2.2.4", | ||||
|     "human-readable-ids": "^1.0.4", | ||||
|     "js-yaml": "^3.11.0", | ||||
|     "jsonwebtoken": "^8.2.1", | ||||
|     "proxy-packer": "^1.4.3", | ||||
|     "jsonwebtoken": "^8.3.0", | ||||
|     "proxy-packer": "^2.0.0", | ||||
|     "recase": "^1.0.4", | ||||
|     "redirect-https": "^1.1.5", | ||||
|     "serve-static": "^1.13.2", | ||||
|  | ||||
							
								
								
									
										24
									
								
								snap/snapcraft.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								snap/snapcraft.yaml
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,24 @@ | ||||
| name: telebit-relay | ||||
| version: '0.20.0' | ||||
| summary: Because friends don't let friends localhost | ||||
| description: | | ||||
|   A server that works in combination with Telebit Remote | ||||
|   to allow you to serve http and https from any computer, | ||||
|   anywhere through a secure tunnel. | ||||
| 
 | ||||
| grade: stable | ||||
| confinement: strict | ||||
| 
 | ||||
| apps: | ||||
|   telebit-relay: | ||||
|     command: telebit-relay --config $SNAP_COMMON/config.yml | ||||
|     plugs: [network, network-bind] | ||||
|     daemon: simple | ||||
| 
 | ||||
| parts: | ||||
|   telebit-relay: | ||||
|     plugin: nodejs | ||||
|     node-engine: 10.13.0 | ||||
|     source: . | ||||
|     override-build: | | ||||
|       snapcraftctl build | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user