diff --git a/apprelays.js b/apprelays.js index d9ba6cbe..29dd0b41 100644 --- a/apprelays.js +++ b/apprelays.js @@ -19,7 +19,8 @@ Protocol numbers 10 = RDP 11 = SSH-TERM 12 = VNC -13 - SSH-FILES +13 = SSH-FILES +14 = Web-TCP */ // Protocol Numbers @@ -58,6 +59,146 @@ const MESHRIGHT_GUESTSHARING = 0x00080000; // 524288 const MESHRIGHT_DEVICEDETAILS = 0x00100000; // 1048576 const MESHRIGHT_ADMIN = 0xFFFFFFFF; + +// Construct a TCP relay object +module.exports.CreateTcpRelay = function (parent, db, req, args, domain) { + const Net = require('net'); + const WebSocket = require('ws'); + + const obj = {}; + obj.relayActive = false; + obj.closed = false; + + // Events + obj.ondata = null; + obj.onconnect = null; + obj.onclose = null; + + // Disconnect + obj.close = function (arg) { + if (obj.closed == true) return; + obj.closed = true; + + // Event the session ending + if ((obj.startTime) && (obj.meshid != null)) { + // Collect how many raw bytes where received and sent. + // We sum both the websocket and TCP client in this case. + var inTraffc = obj.ws._socket.bytesRead, outTraffc = obj.ws._socket.bytesWritten; + if (obj.wsClient != null) { inTraffc += obj.wsClient._socket.bytesRead; outTraffc += obj.wsClient._socket.bytesWritten; } + const sessionSeconds = Math.round((Date.now() - obj.startTime) / 1000); + const user = parent.users[obj.cookie.userid]; + const username = (user != null) ? user.name : null; + const event = { etype: 'relay', action: 'relaylog', domain: domain.id, nodeid: obj.nodeid, userid: obj.cookie.userid, username: username, sessionid: obj.sessionid, msgid: 123, msgArgs: [sessionSeconds, obj.sessionid], msg: "Left Web-SSH session \"" + obj.sessionid + "\" after " + sessionSeconds + " second(s).", protocol: PROTOCOL_WEBSSH, bytesin: inTraffc, bytesout: outTraffc }; + parent.parent.DispatchEvent(['*', obj.nodeid, obj.cookie.userid, obj.meshid], obj, event); + delete obj.startTime; + delete obj.sessionid; + } + if (obj.wsClient) { + obj.wsClient.removeAllListeners('open'); + obj.wsClient.removeAllListeners('message'); + obj.wsClient.removeAllListeners('close'); + try { obj.wsClient.close(); } catch (ex) { console.log(ex); } + delete obj.wsClient; + } + + if ((arg == 1) || (arg == null)) { try { ws.close(); } catch (ex) { console.log(ex); } } // Soft close, close the websocket + if (arg == 2) { try { ws._socket._parent.end(); } catch (ex) { console.log(ex); } } // Hard close, close the TCP socket + obj.ws.removeAllListeners(); + + // Event disconnection + if (obj.onclose) { obj.onclose(); } + + obj.relayActive = false; + delete obj.cookie; + delete obj.nodeid; + delete obj.meshid; + delete obj.userid; + }; + + // Start the looppback server + function startRelayConnection() { + try { + // Setup the correct URL with domain and use TLS only if needed. + const options = { rejectUnauthorized: false }; + const protocol = (args.tlsoffload) ? 'ws' : 'wss'; + var domainadd = ''; + if ((domain.dns == null) && (domain.id != '')) { domainadd = domain.id + '/' } + const url = protocol + '://localhost:' + args.port + '/' + domainadd + (((obj.mtype == 3) && (obj.relaynodeid == null)) ? 'local' : 'mesh') + 'relay.ashx?p=14&auth=' + obj.xcookie; // Protocol 14 is Web-TCP + parent.parent.debug('relay', 'TCP: Connection websocket to ' + url); + obj.wsClient = new WebSocket(url, options); + obj.wsClient.on('open', function () { parent.parent.debug('relay', 'TCP: Relay websocket open'); }); + obj.wsClient.on('message', function (data) { // Make sure to handle flow control. + if (obj.relayActive == false) { + if ((data == 'c') || (data == 'cr')) { + obj.relayActive = true; + if (obj.onconnect) { obj.onconnect(); } // Event connection + } + } else { + if (typeof data == 'string') { + // Forward any ping/pong commands to the browser + var cmd = null; + try { cmd = JSON.parse(data); } catch (ex) { } + if ((cmd != null) && (cmd.ctrlChannel == '102938') && (cmd.type == 'ping')) { cmd.type = 'pong'; obj.wsClient.send(JSON.stringify(cmd)); } + return; + } + // Relay WS --> TCP, event data coming in + if (obj.ondata) { obj.ondata(data); } + } + }); + obj.wsClient.on('close', function () { parent.parent.debug('relay', 'TCP: Relay websocket closed'); obj.close(); }); + obj.wsClient.on('error', function (err) { parent.parent.debug('relay', 'TCP: Relay websocket error: ' + err); obj.close(); }); + } catch (ex) { + console.log(ex); + } + } + + // Send data thru the relay tunnel + obj.send = function (data) { + if (obj.relayActive = - false) return false; + obj.wsClient.send(data); + return true; + } + + parent.parent.debug('relay', 'TCP: Request for TCP relay (' + req.clientIp + ')'); + + // Decode the authentication cookie + obj.cookie = parent.parent.decodeCookie(req.query.auth, parent.parent.loginCookieEncryptionKey); + if ((obj.cookie == null) || (obj.cookie.userid == null) || (parent.users[obj.cookie.userid] == null)) { obj.ws.send(JSON.stringify({ action: 'sessionerror' })); obj.close(); return; } + obj.userid = obj.cookie.userid; + + // Get the meshid for this device + parent.parent.db.Get(obj.cookie.nodeid, function (err, nodes) { + if (obj.cookie == null) return; // obj has been cleaned up, just exit. + if ((err != null) || (nodes == null) || (nodes.length != 1)) { parent.parent.debug('relay', 'TCP: Invalid device'); obj.close(); } + const node = nodes[0]; + obj.nodeid = node._id; // Store the NodeID + obj.meshid = node.meshid; // Store the MeshID + obj.mtype = node.mtype; // Store the device group type + + // Check if we need to relay thru a different agent + const mesh = parent.meshes[obj.meshid]; + if (mesh && mesh.relayid) { + obj.relaynodeid = mesh.relayid; + obj.tcpaddr = node.host; + + // Check if we have rights to the relayid device, does nothing if a relay is not used + checkRelayRights(parent, domain, obj.cookie.userid, obj.relaynodeid, function (allowed) { + if (obj.cookie == null) return; // obj has been cleaned up, just exit. + if (allowed !== true) { parent.parent.debug('relay', 'TCP: Attempt to use un-authorized relay'); obj.close(); return; } + + // Re-encode a cookie with a device relay + const cookieContent = { userid: obj.cookie.userid, domainid: obj.cookie.domainid, nodeid: mesh.relayid, tcpaddr: node.host, tcpport: obj.cookie.tcpport }; + obj.xcookie = parent.parent.encodeCookie(cookieContent, parent.parent.loginCookieEncryptionKey); + }); + } else { + obj.xcookie = req.query.auth; + } + }); + + return obj; +}; + + // Construct a MSTSC Relay object, called upon connection // This implementation does not have TLS support // This is a bit of a hack as we are going to run the RDP connection thru a loopback connection. diff --git a/meshrelay.js b/meshrelay.js index d2915e67..aab96b60 100644 --- a/meshrelay.js +++ b/meshrelay.js @@ -43,6 +43,8 @@ const MESHRIGHT_ADMIN = 0xFFFFFFFF; // 10 = Web-RDP // 11 = Web-SSH // 12 = Web-VNC +// 13 = Web-SSH-Files +// 14 = Web-TCP // 100 = Intel AMT WSMAN // 101 = Intel AMT Redirection // 200 = Messenger diff --git a/views/default.handlebars b/views/default.handlebars index 4a685ffe..e6f688de 100644 --- a/views/default.handlebars +++ b/views/default.handlebars @@ -1449,6 +1449,7 @@ var features = parseInt('{{{features}}}'); var features2 = parseInt('{{{features2}}}'); var sessionTime = parseInt('{{{sessiontime}}}'); + var webRelayPort = parseInt('{{{webRelayPort}}}'); var sessionRefreshTimer = null; var domain = '{{{domain}}}'; var domainUrl = '{{{domainurl}}}'; @@ -2737,7 +2738,7 @@ if (message.name != null) { url += ('&name=' + encodeURIComponentEx(message.name)); } if (message.ip != null) { url += ('&remoteip=' + message.ip); } url += ('&appid=' + message.protocol + '&autoexit=1'); // Protocol: 0 = Custom, 1 = HTTP, 2 = HTTPS, 3 = RDP, 4 = PuTTY, 5 = WinSCP, 6 = MCRDesktop, 7 = MCRFiles - console.log(url); + //console.log(url); downloadFile(url, ''); } else if (message.tag == 'novnc') { var vncurl = window.location.origin + domainUrl + 'novnc/vnc.html?ws=wss%3A%2F%2F' + window.location.host + encodeURIComponentEx(domainUrl) + (message.localRelay?'local':'mesh') + 'relay.ashx%3Fauth%3D' + message.cookie + '&show_dot=1' + (urlargs.key?('&key=' + urlargs.key):'') + '&l={{{lang}}}'; @@ -4573,6 +4574,10 @@ // RDP link, show this link only of the remote machine is Windows. if ((((node.conn & 1) != 0) || (node.mtype == 3)) && (node.agent) && ((meshrights & 8) != 0) && (node.agent.id != 14)) { + if (webRelayPort != 0) { + x += '' + "HTTP" + ' '; + //x += '' + "HTTPS" + ' '; + } if ((node.agent.id > 0) && (node.agent.id < 5)) { if (navigator.platform.toLowerCase() == 'win32') { if ((serverinfo.devicemeshrouterlinks == null) || (serverinfo.devicemeshrouterlinks.rdp != false)) { @@ -7141,6 +7146,10 @@ // RDP link, show this link only of the remote machine is Windows. if ((((connectivity & 1) != 0) || (node.mtype == 3)) && (node.agent) && ((meshrights & 8) != 0)) { + if (webRelayPort != 0) { + x += '' + "HTTP" + ' '; + //x += '' + "HTTPS" + ' '; + } if ((node.agent.id > 0) && (node.agent.id < 5)) { if (navigator.platform.toLowerCase() == 'win32') { if ((serverinfo.devicemeshrouterlinks == null) || (serverinfo.devicemeshrouterlinks.rdp != false)) { @@ -8063,6 +8072,22 @@ meshserver.send({ action: 'removedevices', nodeids: [ nodeid ] }); } + function p10WebRouter(nodeid, protocol, port, addr) { + var relayid = null; + var node = getNodeFromId(nodeid); + if (node.mtype == 3) { // Setup device relay if needed + var mesh = meshes[node.meshid]; + if (mesh && mesh.relayid) { relayid = mesh.relayid; addr = node.host; } + } + var servername = serverinfo.name; + if ((servername.indexOf('.') == -1) || ((features & 2) != 0)) { servername = window.location.hostname; } // If the server name is not set or it's in LAN-only mode, use the URL hostname as server name. + var url = 'https://' + servername + ':' + webRelayPort + '/control-redirect.ashx?n=' + nodeid + '&p=' + port + '&appid=' + protocol; // Protocol: 1 = HTTP, 2 = HTTPS + if (addr != null) { url += '&addr=' + addr; } + if (relayid != null) { url += '&relayid=' + relayid; } + safeNewWindow(url, 'WebRelay'); + return false; + } + function p10MCRouter(nodeid, protocol, port, addr, localport) { var node = getNodeFromId(nodeid); var mesh = meshes[node.meshid]; diff --git a/webrelayserver.js b/webrelayserver.js index 93b73556..bed4305c 100644 --- a/webrelayserver.js +++ b/webrelayserver.js @@ -24,11 +24,35 @@ module.exports.CreateWebRelayServer = function (parent, db, args, certificates, obj.net = require('net'); obj.app = obj.express(); obj.webRelayServer = null; - obj.port = null; + obj.port = 0; + obj.relayTunnels = {} // RelayID --> Web Tunnel const constants = (require('crypto').constants ? require('crypto').constants : require('constants')); // require('constants') is deprecated in Node 11.10, use require('crypto').constants instead. var tlsSessionStore = {}; // Store TLS session information for quick resume. var tlsSessionStoreCount = 0; // Number of cached TLS session information in store. + if (args.trustedproxy) { + // Reverse proxy should add the "X-Forwarded-*" headers + try { + obj.app.set('trust proxy', args.trustedproxy); + } catch (ex) { + // If there is an error, try to resolve the string + if ((args.trustedproxy.length == 1) && (typeof args.trustedproxy[0] == 'string')) { + require('dns').lookup(args.trustedproxy[0], function (err, address, family) { if (err == null) { obj.app.set('trust proxy', address); args.trustedproxy = [address]; } }); + } + } + } + else if (typeof args.tlsoffload == 'object') { + // Reverse proxy should add the "X-Forwarded-*" headers + try { + obj.app.set('trust proxy', args.tlsoffload); + } catch (ex) { + // If there is an error, try to resolve the string + if ((Array.isArray(args.tlsoffload)) && (args.tlsoffload.length == 1) && (typeof args.tlsoffload[0] == 'string')) { + require('dns').lookup(args.tlsoffload[0], function (err, address, family) { if (err == null) { obj.app.set('trust proxy', address); args.tlsoffload = [address]; } }); + } + } + } + // Add HTTP security headers to all responses obj.app.use(function (req, res, next) { parent.debug('webrequest', req.url + ' (RelayServer)'); @@ -41,9 +65,50 @@ module.exports.CreateWebRelayServer = function (parent, db, args, certificates, 'X-Content-Type-Options': 'nosniff', 'Content-Security-Policy': "default-src 'none'; style-src 'self' 'unsafe-inline';" }); + + // Set the real IP address of the request + // If a trusted reverse-proxy is sending us the remote IP address, use it. + var ipex = '0.0.0.0', xforwardedhost = req.headers.host; + if (typeof req.connection.remoteAddress == 'string') { ipex = (req.connection.remoteAddress.startsWith('::ffff:')) ? req.connection.remoteAddress.substring(7) : req.connection.remoteAddress; } + if ( + (args.trustedproxy === true) || (args.tlsoffload === true) || + ((typeof args.trustedproxy == 'object') && (isIPMatch(ipex, args.trustedproxy))) || + ((typeof args.tlsoffload == 'object') && (isIPMatch(ipex, args.tlsoffload))) + ) { + // Get client IP + if (req.headers['cf-connecting-ip']) { // Use CloudFlare IP address if present + req.clientIp = req.headers['cf-connecting-ip'].split(',')[0].trim(); + } else if (req.headers['x-forwarded-for']) { + req.clientIp = req.headers['x-forwarded-for'].split(',')[0].trim(); + } else if (req.headers['x-real-ip']) { + req.clientIp = req.headers['x-real-ip'].split(',')[0].trim(); + } else { + req.clientIp = ipex; + } + + // If there is a port number, remove it. This will only work for IPv4, but nice for people that have a bad reverse proxy config. + const clientIpSplit = req.clientIp.split(':'); + if (clientIpSplit.length == 2) { req.clientIp = clientIpSplit[0]; } + + // Get server host + if (req.headers['x-forwarded-host']) { xforwardedhost = req.headers['x-forwarded-host'].split(',')[0]; } // If multiple hosts are specified with a comma, take the first one. + } else { + req.clientIp = ipex; + } + return next(); }); + // This is the magic URL that will setup the relay session + obj.app.get('/control-redirect.ashx', function (req, res) { + res.set({ 'Cache-Control': 'no-store' }); + parent.debug('web', 'webRelaySetup'); + + console.log('req.query', req.query); + + res.redirect('/'); + }); + // Start the server, only after users and meshes are loaded from the database. if (args.tlsoffload) { // Setup the HTTP server without TLS @@ -86,6 +151,7 @@ module.exports.CreateWebRelayServer = function (parent, db, args, certificates, obj.parent.updateServerState('http-relay-port', port); if (args.aliasport != null) { obj.parent.updateServerState('http-relay-aliasport', args.aliasport); } } + obj.port = port; } CheckListenPort(args.relayport, args.relayportbind, StartWebRelayServer); diff --git a/webserver.js b/webserver.js index b4b6be3e..10e033d7 100644 --- a/webserver.js +++ b/webserver.js @@ -2858,7 +2858,8 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF footer: (domain.footer == null) ? '' : domain.footer, webstate: encodeURIComponent(webstate).replace(/'/g, '%27'), amtscanoptions: amtscanoptions, - pluginHandler: (parent.pluginHandler == null) ? 'null' : parent.pluginHandler.prepExports() + pluginHandler: (parent.pluginHandler == null) ? 'null' : parent.pluginHandler.prepExports(), + webRelayPort: ((parent.webrelayserver != null) ? parent.webrelayserver.port : 0) }, dbGetFunc.req, domain), user); } xdbGetFunc.req = req; @@ -5846,11 +5847,19 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF var selfurl = ' wss://' + req.headers.host; if ((xforwardedhost != null) && (xforwardedhost != req.headers.host)) { selfurl += ' wss://' + xforwardedhost; } const extraScriptSrc = (parent.config.settings.extrascriptsrc != null) ? (' ' + parent.config.settings.extrascriptsrc) : ''; + + // If the web relay port is enabled, allow the web page to redirect to it + var extraFrameSrc = ''; + if ((parent.webrelayserver != null) && (parent.webrelayserver.port != 0)) { + extraFrameSrc = ' https://' + req.headers.host + ':' + parent.webrelayserver.port; + if ((xforwardedhost != null) && (xforwardedhost != req.headers.host)) { extraFrameSrc += ' https://' + xforwardedhost + ':' + parent.webrelayserver.port; } + } + const headers = { 'Referrer-Policy': 'no-referrer', 'X-XSS-Protection': '1; mode=block', 'X-Content-Type-Options': 'nosniff', - 'Content-Security-Policy': "default-src 'none'; font-src 'self'; script-src 'self' 'unsafe-inline'" + extraScriptSrc + "; connect-src 'self'" + geourl + selfurl + "; img-src 'self' blob: data:" + geourl + " data:; style-src 'self' 'unsafe-inline'; frame-src 'self' mcrouter:; media-src 'self'; form-action 'self'" + 'Content-Security-Policy': "default-src 'none'; font-src 'self'; script-src 'self' 'unsafe-inline'" + extraScriptSrc + "; connect-src 'self'" + geourl + selfurl + "; img-src 'self' blob: data:" + geourl + " data:; style-src 'self' 'unsafe-inline'; frame-src 'self' mcrouter:" + extraFrameSrc + "; media-src 'self'; form-action 'self'" }; if (req.headers['user-agent'] && (req.headers['user-agent'].indexOf('Chrome') >= 0)) { headers['Permissions-Policy'] = 'interest-cohort=()'; } // Remove Google's FLoC Network, only send this if Chrome browser if ((parent.config.settings.allowframing !== true) && (typeof parent.config.settings.allowframing !== 'string')) { headers['X-Frame-Options'] = 'sameorigin'; }