From e744813598cffff8172b7ba3e422c0ce8d1aa569 Mon Sep 17 00:00:00 2001 From: Ylian Saint-Hilaire Date: Wed, 26 Aug 2020 18:42:41 -0700 Subject: [PATCH] Added support for device stream downloads. --- MeshCentralServer.njsproj | 3 +- agents/meshcore.js | 30 ++++- meshctrl.js | 1 + meshdevicefile.js | 266 ++++++++++++++++++++++++++++++++++++++ views/default.handlebars | 7 +- webserver.js | 27 ++++ 6 files changed, 325 insertions(+), 9 deletions(-) create mode 100644 meshdevicefile.js diff --git a/MeshCentralServer.njsproj b/MeshCentralServer.njsproj index 2fc900ee..d303bf76 100644 --- a/MeshCentralServer.njsproj +++ b/MeshCentralServer.njsproj @@ -107,6 +107,7 @@ + @@ -115,7 +116,7 @@ - + diff --git a/agents/meshcore.js b/agents/meshcore.js index 197763e6..c383c86b 100644 --- a/agents/meshcore.js +++ b/agents/meshcore.js @@ -1239,7 +1239,7 @@ function createMeshCore(agent) { } // Sent tunnel statistics to the server, only send this if compression was used. - if (this.bytesSent_uncompressed.toString() != this.bytesSent_actual.toString()) { + if ((this.bytesSent_uncompressed) && (this.bytesSent_uncompressed.toString() != this.bytesSent_actual.toString())) { mesh.SendCommand({ action: 'tunnelCloseStats', url: tunnel.url, @@ -1256,7 +1256,7 @@ function createMeshCore(agent) { } //sendConsoleText("Tunnel #" + this.httprequest.index + " closed. Sent -> " + this.bytesSent_uncompressed + ' bytes (uncompressed), ' + this.bytesSent_actual + ' bytes (actual), ' + this.bytesSent_ratio + '% compression', this.httprequest.sessionid); - delete tunnels[this.httprequest.index]; + if (this.httprequest.index) { delete tunnels[this.httprequest.index]; } /* // Close the watcher if required @@ -1269,7 +1269,7 @@ function createMeshCore(agent) { // If there is a upload or download active on this connection, close the file if (this.httprequest.uploadFile) { fs.closeSync(this.httprequest.uploadFile); delete this.httprequest.uploadFile; delete this.httprequest.uploadFileid; delete this.httprequest.uploadFilePath; } - if (this.httprequest.downloadFile) { fs.closeSync(this.httprequest.downloadFile); delete this.httprequest.downloadFile; } + if (this.httprequest.downloadFile) { delete this.httprequest.downloadFile; } // Clean up WebRTC if (this.webrtc != null) { @@ -1311,12 +1311,29 @@ function createMeshCore(agent) { else { // Handle tunnel data - if (this.httprequest.protocol == 0) { // 1 = Terminal (admin), 2 = Desktop, 5 = Files, 6 = PowerShell (admin), 7 = Plugin Data Exchange, 8 = Terminal (user), 9 = PowerShell (user) + if (this.httprequest.protocol == 0) { // 1 = Terminal (admin), 2 = Desktop, 5 = Files, 6 = PowerShell (admin), 7 = Plugin Data Exchange, 8 = Terminal (user), 9 = PowerShell (user), 10 = FileTransfer // Take a look at the protocol if ((data.length > 3) && (data[0] == '{')) { onTunnelControlData(data, this); return; } this.httprequest.protocol = parseInt(data); if (typeof this.httprequest.protocol != 'number') { this.httprequest.protocol = 0; } - if ((this.httprequest.protocol == 1) || (this.httprequest.protocol == 6) || (this.httprequest.protocol == 8) || (this.httprequest.protocol == 9)) + if (this.httprequest.protocol == 10) { + // + // Basic file transfer + // + var stats = null; + try { stats = require('fs').statSync(this.httprequest.xoptions.file) } catch (e) { } + try { if (stats) { this.httprequest.downloadFile = fs.createReadStream(this.httprequest.xoptions.file, { flags: 'rbN' }); } } catch (e) { } + if (this.httprequest.downloadFile) { + sendConsoleText('BasicFileTransfer, ok, ' + this.httprequest.xoptions.file + ', ' + JSON.stringify(stats)); + this.write(JSON.stringify({ op: 'ok', size: stats.size })); + this.httprequest.downloadFile.pipe(this); + this.httprequest.downloadFile.end = function () { } + } else { + sendConsoleText('BasicFileTransfer, cancel, ' + this.httprequest.xoptions.file); + this.write(JSON.stringify({ op: 'cancel' })); + } + } + else if ((this.httprequest.protocol == 1) || (this.httprequest.protocol == 6) || (this.httprequest.protocol == 8) || (this.httprequest.protocol == 9)) { // // Remote Terminal @@ -1808,8 +1825,7 @@ function createMeshCore(agent) { MeshServerLog("Failed to start remote desktop after local user rejected (" + this.ws.httprequest.remoteaddr + ")", this.ws.httprequest); this.ws.end(JSON.stringify({ ctrlChannel: '102938', type: 'console', msg: e.toString(), msgid: 2 })); }); - } - else { + } else { // User Consent Prompt is not required if (this.httprequest.consent && (this.httprequest.consent & 1)) { // User Notifications is required diff --git a/meshctrl.js b/meshctrl.js index e9edcc1a..cae48e76 100644 --- a/meshctrl.js +++ b/meshctrl.js @@ -1066,6 +1066,7 @@ function serverConnect() { if (settings.cmd == 'showevents') { console.log(data); return; } switch (data.action) { case 'serverinfo': { // SERVERINFO + console.log(data); settings.currentDomain = data.serverinfo.domain; if (settings.cmd == 'serverinfo') { if (args.json) { diff --git a/meshdevicefile.js b/meshdevicefile.js new file mode 100644 index 00000000..5ea25b1d --- /dev/null +++ b/meshdevicefile.js @@ -0,0 +1,266 @@ +/** +* @description MeshCentral device file download relay module +* @author Ylian Saint-Hilaire +* @copyright Intel Corporation 2018-2020 +* @license Apache-2.0 +* @version v0.0.1 +*/ + +/*jslint node: true */ +/*jshint node: true */ +/*jshint strict:false */ +/*jshint -W097 */ +/*jshint esversion: 6 */ +"use strict"; + +module.exports.CreateMeshDeviceFile = function (parent, ws, res, req, domain, user, meshid, nodeid) { + var obj = {}; + obj.ws = ws; + obj.res = res; + obj.user = user; + obj.ruserid = null; + obj.req = req; // Used in multi-server.js + obj.id = req.query.id; + obj.file = req.query.f; + + // Check relay authentication + if ((user == null) && (obj.req.query != null) && (obj.req.query.rauth != null)) { + const rcookie = parent.parent.decodeCookie(obj.req.query.rauth, parent.parent.loginCookieEncryptionKey, 240); // Cookie with 4 hour timeout + if (rcookie.ruserid != null) { obj.ruserid = rcookie.ruserid; } + } + + // Relay session count (we may remove this in the future) + obj.relaySessionCounted = true; + parent.relaySessionCount++; + + // Clean a IPv6 address that encodes a IPv4 address + function cleanRemoteAddr(addr) { if (addr.startsWith('::ffff:')) { return addr.substring(7); } else { return addr; } } + + // Disconnect + obj.close = function (arg) { + if (obj.ws != null) { + if ((arg == 1) || (arg == null)) { try { obj.ws.close(); parent.parent.debug('relay', 'FileRelay: Soft disconnect (' + obj.req.clientIp + ')'); } catch (ex) { console.log(e); } } // Soft close, close the websocket + if (arg == 2) { try { obj.ws._socket._parent.end(); parent.parent.debug('relay', 'FileRelay: Hard disconnect (' + obj.req.clientIp + ')'); } catch (ex) { console.log(e); } } // Hard close, close the TCP socket + } else if (obj.res != null) { + try { res.sendStatus(404); } catch (ex) { } + } + + // Aggressive cleanup + delete obj.ws; + delete obj.res; + delete obj.peer; + }; + + // If there is no authentication, drop this connection + if ((obj.id == null) || ((obj.user == null) && (obj.ruserid == null))) { try { obj.close(); parent.parent.debug('relay', 'FileRelay: Connection with no authentication (' + obj.req.clientIp + ')'); } catch (e) { console.log(e); } return; } + + obj.sendAgentMessage = function (command, user, domainid) { + var rights, mesh; + if (command.nodeid == null) return false; + var splitnodeid = command.nodeid.split('/'); + // Check that we are in the same domain and the user has rights over this node. + if ((splitnodeid[0] == 'node') && (splitnodeid[1] == domainid)) { + // Get the user object + // See if the node is connected + var agent = parent.wsagents[command.nodeid]; + if (agent != null) { + // Check if we have permission to send a message to that node + rights = parent.GetNodeRights(user, agent.dbMeshKey, agent.dbNodeKey); + mesh = parent.meshes[agent.dbMeshKey]; + if ((rights != null) && (mesh != null) || ((rights & 16) != 0)) { // TODO: 16 is console permission, may need more gradular permission checking + command.rights = rights; // Add user rights flags to the message + if (typeof command.consent == 'number') { command.consent = command.consent | mesh.consent; } else { command.consent = mesh.consent; } // Add user consent + if (typeof domain.userconsentflags == 'number') { command.consent |= domain.userconsentflags; } // Add server required consent flags + command.username = user.name; // Add user name + command.realname = user.realname; // Add real name + if (typeof domain.desktopprivacybartext == 'string') { command.privacybartext = domain.desktopprivacybartext; } // Privacy bar text + delete command.nodeid; // Remove the nodeid since it's implyed. + agent.send(JSON.stringify(command)); + return true; + } + } else { + // Check if a peer server is connected to this agent + var routing = parent.parent.GetRoutingServerId(command.nodeid, 1); // 1 = MeshAgent routing type + if (routing != null) { + // Check if we have permission to send a message to that node + rights = parent.GetNodeRights(user, routing.meshid, command.nodeid); + mesh = parent.meshes[routing.meshid]; + if (rights != null || ((rights & 16) != 0)) { // TODO: 16 is console permission, may need more gradular permission checking + command.rights = rights; // Add user rights flags to the message + if (typeof command.consent == 'number') { command.consent = command.consent | mesh.consent; } else { command.consent = mesh.consent; } // Add user consent + if (typeof domain.userconsentflags == 'number') { command.consent |= domain.userconsentflags; } // Add server required consent flags + command.username = user.name; // Add user name + command.realname = user.realname; // Add real name + if (typeof domain.desktopprivacybartext == 'string') { command.privacybartext = domain.desktopprivacybartext; } // Privacy bar text + parent.parent.multiServer.DispatchMessageSingleServer(command, routing.serverid); + return true; + } + } + } + } + return false; + }; + + function performRelay() { + if (obj.id == null) { try { obj.close(); } catch (e) { } return null; } // Attempt to connect without id, drop this. + if (obj.ws != null) { obj.ws._socket.setKeepAlive(true, 240000); } // Set TCP keep alive + + // Check the peer connection status + { + var relayinfo = parent.wsrelays[obj.id]; + if (relayinfo) { + if (relayinfo.state == 1) { + + // Check that at least one connection is authenticated + if ((obj.authenticated != true) && (relayinfo.peer1.authenticated != true)) { + if (ws) { ws.close(); } + parent.parent.debug('relay', 'FileRelay without-auth: ' + obj.id + ' (' + obj.req.clientIp + ')'); + delete obj.id; + delete obj.ws; + delete obj.peer; + return null; + } + + // Connect to peer + obj.peer = relayinfo.peer1; + obj.peer.peer = obj; + relayinfo.peer2 = obj; + relayinfo.state = 2; + + // Remove the timeout + if (relayinfo.timeout) { clearTimeout(relayinfo.timeout); delete relayinfo.timeout; } + + var agentws = null, file = null; + if (relayinfo.peer1.ws) { relayinfo.peer1.ws.res = relayinfo.peer2.res; relayinfo.peer1.ws.res = relayinfo.peer2.res; relayinfo.peer1.ws.file = relayinfo.peer2.file; agentws = relayinfo.peer1.ws; file = relayinfo.peer2.file; } + if (relayinfo.peer2.ws) { relayinfo.peer2.ws.res = relayinfo.peer1.res; relayinfo.peer2.ws.res = relayinfo.peer1.res; relayinfo.peer2.ws.file = relayinfo.peer1.file; agentws = relayinfo.peer2.ws; file = relayinfo.peer1.file; } + agentws._socket.resume(); // Release the traffic + try { agentws.send('c'); } catch (ex) { } // Send connect to agent + try { agentws.send(JSON.stringify({ type: 'options', file: file })); } catch (ex) { } // Send options to agent + try { agentws.send('10'); } catch (ex) { } // Send file transfer protocol to agent + + parent.parent.debug('relay', 'FileRelay connected: ' + obj.id + ' (' + obj.req.clientIp + ' --> ' + obj.peer.req.clientIp + ')'); + + // Log the connection + if (sessionUser != null) { + var event = { etype: 'relay', action: 'relaylog', domain: domain.id, userid: sessionUser._id, username: sessionUser.name, msg: "Started file transfer session" + ' \"' + obj.id + '\" from ' + obj.peer.req.clientIp + ' to ' + req.clientIp, protocol: req.query.p, nodeid: req.query.nodeid }; + parent.parent.DispatchEvent(['*', sessionUser._id], obj, event); + } + } else { + // Connected already, drop this connection. + if (obj.ws) { obj.ws.close(); } + parent.parent.debug('relay', 'FileRelay duplicate: ' + obj.id + ' (' + obj.req.clientIp + ')'); + delete obj.id; + delete obj.ws; + delete obj.peer; + return null; + } + } else { + // Wait for other relay connection + parent.wsrelays[obj.id] = { peer1: obj, state: 1, timeout: setTimeout(closeBothSides, 30000) }; + parent.parent.debug('relay', 'FileRelay holding: ' + obj.id + ' (' + obj.req.clientIp + ') ' + (obj.authenticated ? 'Authenticated' : '')); + + // Check if a peer server has this connection + if (parent.parent.multiServer != null) { + var rsession = parent.wsPeerRelays[obj.id]; + if ((rsession != null) && (rsession.serverId > parent.parent.serverId)) { + // We must initiate the connection to the peer + parent.parent.multiServer.createPeerRelay(ws, req, rsession.serverId, obj.req.session.userid); + delete parent.wsrelays[obj.id]; + } else { + // Send message to other peers that we have this connection + parent.parent.multiServer.DispatchMessage(JSON.stringify({ action: 'relay', id: obj.id })); + } + } + } + } + } + + // Websocket handling + if (obj.ws != null) { + // When data is received from the mesh relay web socket + obj.ws.on('message', function (data) { + if (typeof data == 'string') { + var cmd = null; + try { cmd = JSON.parse(data); } catch (ex) { } + if ((cmd == null) || (typeof cmd.op == 'string')) { + if (cmd.op == 'ok') { + if (typeof cmd.size == 'number') { + this.res.set({ 'Cache-Control': 'no-cache, no-store, must-revalidate', 'Pragma': 'no-cache', 'Expires': '0', 'Content-Type': 'application/octet-stream', 'Content-Disposition': 'attachment; filename="' + require('path').basename(this.file) + '"', 'Content-Length': cmd.size }); + } else { + this.res.set({ 'Cache-Control': 'no-cache, no-store, must-revalidate', 'Pragma': 'no-cache', 'Expires': '0', 'Content-Type': 'application/octet-stream', 'Content-Disposition': 'attachment; filename="' + require('path').basename(this.file) + '"' }); + } + } else { + try { this.res.sendStatus(401); } catch (ex) { } + } + } + } else { + var unpause = function unpauseFunc(err) { try { unpauseFunc.s.resume(); } catch (ex) { } } + unpause.s = this._socket; + this._socket.pause(); + try { this.res.write(data, unpause); } catch (ex) { } + } + }); + + // If error, close both sides of the relay. + obj.ws.on('error', function (err) { + parent.relaySessionErrorCount++; + //console.log('FileRelay error from ' + obj.req.clientIp + ', ' + err.toString().split('\r')[0] + '.'); + closeBothSides(); + }); + + // If the relay web socket is closed, close both sides. + obj.ws.on('close', function (req) { closeBothSides(); }); + } + + // Close both our side and the peer side. + function closeBothSides() { + if (obj.relaySessionCounted) { parent.relaySessionCount--; delete obj.relaySessionCounted; } + + if (obj.id != null) { + var relayinfo = parent.wsrelays[obj.id]; + if (relayinfo != null) { + if (relayinfo.state == 2) { + var peer = (relayinfo.peer1 == obj) ? relayinfo.peer2 : relayinfo.peer1; + + // Disconnect the peer + try { if (peer.relaySessionCounted) { parent.relaySessionCount--; delete peer.relaySessionCounted; } } catch (ex) { console.log(ex); } + parent.parent.debug('relay', 'FileRelay disconnect: ' + obj.id + ' (' + obj.req.clientIp + ' --> ' + peer.req.clientIp + ')'); + if (peer.ws) { try { peer.ws.close(); } catch (e) { } try { peer.ws._socket._parent.end(); } catch (e) { } } + if (peer.res) { try { peer.res.end(); } catch (ex) { } } + + // Aggressive peer cleanup + delete peer.id; + delete peer.ws; + delete peer.res; + delete peer.peer; + } else { + parent.parent.debug('relay', 'FileRelay disconnect: ' + obj.id + ' (' + obj.req.clientIp + ')'); + } + + if (obj.ws) { try { obj.ws.close(); } catch (ex) { } } + if (obj.res) { try { obj.res.end(); } catch (ex) { } } + delete parent.wsrelays[obj.id]; + } + } + + // Aggressive cleanup + delete obj.id; + delete obj.ws; + delete obj.res; + delete obj.peer; + } + + // Mark this relay session as authenticated if this is the user end. + obj.authenticated = (user != null); + if (obj.authenticated) { + // Send connection request to agent + const rcookie = parent.parent.encodeCookie({ ruserid: user._id }, parent.parent.loginCookieEncryptionKey); + const command = { nodeid: nodeid, action: 'msg', type: 'tunnel', userid: user._id, value: '*/devicefile.ashx?id=' + obj.id + '&rauth=' + rcookie, soptions: {} }; + parent.parent.debug('relay', 'FileRelay: Sending agent tunnel command: ' + JSON.stringify(command)); + if (obj.sendAgentMessage(command, user, domain.id) == false) { delete obj.id; parent.parent.debug('relay', 'FileRelay: Unable to contact this agent (' + obj.req.clientIp + ')'); } + } + + // If this is not an authenticated session, or the session does not have routing instructions, just go ahead an connect to existing session. + performRelay(); + return obj; +}; diff --git a/views/default.handlebars b/views/default.handlebars index 2e8ec5bf..402acec2 100644 --- a/views/default.handlebars +++ b/views/default.handlebars @@ -7874,7 +7874,12 @@ h = '
 ' + right + '
' + shortname + '
'; } else { var link = shortname; - if (f.s > 0) { link = '' + shortname + ''; } + if (f.s > 0) { + // Local link + //link = '' + shortname + ''; + // Server link + link = '' + shortname + ''; + } h = '
 ' + fdatestr + '' + EscapeHtml(fsize) + '
' + link + '
'; } diff --git a/webserver.js b/webserver.js index b98a99de..09ab4fe3 100644 --- a/webserver.js +++ b/webserver.js @@ -57,6 +57,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates) { obj.express = require('express'); obj.meshAgentHandler = require('./meshagent.js'); obj.meshRelayHandler = require('./meshrelay.js'); + obj.meshDeviceFileHandler = require('./meshdevicefile.js'); obj.meshDesktopMultiplexHandler = require('./meshdesktopmultiplex.js'); obj.meshIderHandler = require('./amt/amt-ider.js'); obj.meshUserHandler = require('./meshuser.js'); @@ -2786,6 +2787,30 @@ module.exports.CreateWebServer = function (parent, db, args, certificates) { } } + // Handle device file request + function handleDeviceFile(req, res) { + const domain = checkUserIpAddress(req, res); + if (domain == null) { return; } + if ((req.query.c == null) || (req.query.m == null) || (req.query.n == null) || (req.query.f == null)) { res.sendStatus(404); return; } + + // Check the inbound desktop sharing cookie + var c = obj.parent.decodeCookie(req.query.c, obj.parent.loginCookieEncryptionKey, 60); // 60 minute timeout + if ((c == null) || (c.domainid !== domain.id)) { res.sendStatus(404); return; } + + // Check userid + const user = obj.users[c.userid]; + if ((c == user)) { res.sendStatus(404); return; } + + // Check if this user has permission to manage this computer + const meshid = 'mesh/' + domain.id + '/' + req.query.m; + const nodeid = 'node/' + domain.id + '/' + req.query.n; + if ((obj.GetNodeRights(c.userid, meshid, nodeid) & MESHRIGHT_REMOTECONTROL) == 0) { res.sendStatus(404); return; } + + // All good, start the file transfer + req.query.id = getRandomLowerCase(12); + obj.meshDeviceFileHandler.CreateMeshDeviceFile(obj, null, res, req, domain, user, meshid, nodeid); + } + // Handle logo request function handleLogoRequest(req, res) { const domain = checkUserIpAddress(req, res); @@ -4677,6 +4702,8 @@ module.exports.CreateWebServer = function (parent, db, args, certificates) { obj.app.ws(url + 'webrelay.ashx', function (ws, req) { PerformWSSessionAuth(ws, req, false, handleRelayWebSocket); }); obj.app.ws(url + 'webider.ashx', function (ws, req) { PerformWSSessionAuth(ws, req, false, function (ws1, req1, domain, user, cookie) { obj.meshIderHandler.CreateAmtIderSession(obj, obj.db, ws1, req1, obj.args, domain, user); }); }); obj.app.ws(url + 'control.ashx', function (ws, req) { PerformWSSessionAuth(ws, req, false, function (ws1, req1, domain, user, cookie) { obj.meshUserHandler.CreateMeshUser(obj, obj.db, ws1, req1, obj.args, domain, user); }); }); + obj.app.ws(url + 'devicefile.ashx', function (ws, req) { obj.meshDeviceFileHandler.CreateMeshDeviceFile(obj, ws, null, req, domain); }); + obj.app.get(url + 'devicefile.ashx', handleDeviceFile); obj.app.get(url + 'logo.png', handleLogoRequest); obj.app.post(url + 'translations', handleTranslationsRequest); obj.app.get(url + 'welcome.jpg', handleWelcomeImageRequest);