More work on web relay, #4172

Ylian Saint-Hilaire committed Jun 24, 2022 at 16:53 UTC 571a0f1c2d5807d6e3fb9b8c98c7a5c339acf54b
5 files changed +248 -5
apprelays.js
+142 -1
@@ -19,7 +19,8 @@ Protocol numbers
19 10 = RDP
20 11 = SSH-TERM
21 12 = VNC
22 -13 - SSH-FILES
22 +13 = SSH-FILES
23 +14 = Web-TCP
24 */
25
26 // Protocol Numbers
@@ -58,6 +59,146 @@ const MESHRIGHT_GUESTSHARING = 0x00080000; // 524288
59 const MESHRIGHT_DEVICEDETAILS = 0x00100000; // 1048576
60 const MESHRIGHT_ADMIN = 0xFFFFFFFF;
61
62 +
63 +// Construct a TCP relay object
64 +module.exports.CreateTcpRelay = function (parent, db, req, args, domain) {
65 + const Net = require('net');
66 + const WebSocket = require('ws');
67 +
68 + const obj = {};
69 + obj.relayActive = false;
70 + obj.closed = false;
71 +
72 + // Events
73 + obj.ondata = null;
74 + obj.onconnect = null;
75 + obj.onclose = null;
76 +
77 + // Disconnect
78 + obj.close = function (arg) {
79 + if (obj.closed == true) return;
80 + obj.closed = true;
81 +
82 + // Event the session ending
83 + if ((obj.startTime) && (obj.meshid != null)) {
84 + // Collect how many raw bytes where received and sent.
85 + // We sum both the websocket and TCP client in this case.
86 + var inTraffc = obj.ws._socket.bytesRead, outTraffc = obj.ws._socket.bytesWritten;
87 + if (obj.wsClient != null) { inTraffc += obj.wsClient._socket.bytesRead; outTraffc += obj.wsClient._socket.bytesWritten; }
88 + const sessionSeconds = Math.round((Date.now() - obj.startTime) / 1000);
89 + const user = parent.users[obj.cookie.userid];
90 + const username = (user != null) ? user.name : null;
91 + 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 };
92 + parent.parent.DispatchEvent(['*', obj.nodeid, obj.cookie.userid, obj.meshid], obj, event);
93 + delete obj.startTime;
94 + delete obj.sessionid;
95 + }
96 + if (obj.wsClient) {
97 + obj.wsClient.removeAllListeners('open');
98 + obj.wsClient.removeAllListeners('message');
99 + obj.wsClient.removeAllListeners('close');
100 + try { obj.wsClient.close(); } catch (ex) { console.log(ex); }
101 + delete obj.wsClient;
102 + }
103 +
104 + if ((arg == 1) || (arg == null)) { try { ws.close(); } catch (ex) { console.log(ex); } } // Soft close, close the websocket
105 + if (arg == 2) { try { ws._socket._parent.end(); } catch (ex) { console.log(ex); } } // Hard close, close the TCP socket
106 + obj.ws.removeAllListeners();
107 +
108 + // Event disconnection
109 + if (obj.onclose) { obj.onclose(); }
110 +
111 + obj.relayActive = false;
112 + delete obj.cookie;
113 + delete obj.nodeid;
114 + delete obj.meshid;
115 + delete obj.userid;
116 + };
117 +
118 + // Start the looppback server
119 + function startRelayConnection() {
120 + try {
121 + // Setup the correct URL with domain and use TLS only if needed.
122 + const options = { rejectUnauthorized: false };
123 + const protocol = (args.tlsoffload) ? 'ws' : 'wss';
124 + var domainadd = '';
125 + if ((domain.dns == null) && (domain.id != '')) { domainadd = domain.id + '/' }
126 + 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
127 + parent.parent.debug('relay', 'TCP: Connection websocket to ' + url);
128 + obj.wsClient = new WebSocket(url, options);
129 + obj.wsClient.on('open', function () { parent.parent.debug('relay', 'TCP: Relay websocket open'); });
130 + obj.wsClient.on('message', function (data) { // Make sure to handle flow control.
131 + if (obj.relayActive == false) {
132 + if ((data == 'c') || (data == 'cr')) {
133 + obj.relayActive = true;
134 + if (obj.onconnect) { obj.onconnect(); } // Event connection
135 + }
136 + } else {
137 + if (typeof data == 'string') {
138 + // Forward any ping/pong commands to the browser
139 + var cmd = null;
140 + try { cmd = JSON.parse(data); } catch (ex) { }
141 + if ((cmd != null) && (cmd.ctrlChannel == '102938') && (cmd.type == 'ping')) { cmd.type = 'pong'; obj.wsClient.send(JSON.stringify(cmd)); }
142 + return;
143 + }
144 + // Relay WS --> TCP, event data coming in
145 + if (obj.ondata) { obj.ondata(data); }
146 + }
147 + });
148 + obj.wsClient.on('close', function () { parent.parent.debug('relay', 'TCP: Relay websocket closed'); obj.close(); });
149 + obj.wsClient.on('error', function (err) { parent.parent.debug('relay', 'TCP: Relay websocket error: ' + err); obj.close(); });
150 + } catch (ex) {
151 + console.log(ex);
152 + }
153 + }
154 +
155 + // Send data thru the relay tunnel
156 + obj.send = function (data) {
157 + if (obj.relayActive = - false) return false;
158 + obj.wsClient.send(data);
159 + return true;
160 + }
161 +
162 + parent.parent.debug('relay', 'TCP: Request for TCP relay (' + req.clientIp + ')');
163 +
164 + // Decode the authentication cookie
165 + obj.cookie = parent.parent.decodeCookie(req.query.auth, parent.parent.loginCookieEncryptionKey);
166 + if ((obj.cookie == null) || (obj.cookie.userid == null) || (parent.users[obj.cookie.userid] == null)) { obj.ws.send(JSON.stringify({ action: 'sessionerror' })); obj.close(); return; }
167 + obj.userid = obj.cookie.userid;
168 +
169 + // Get the meshid for this device
170 + parent.parent.db.Get(obj.cookie.nodeid, function (err, nodes) {
171 + if (obj.cookie == null) return; // obj has been cleaned up, just exit.
172 + if ((err != null) || (nodes == null) || (nodes.length != 1)) { parent.parent.debug('relay', 'TCP: Invalid device'); obj.close(); }
173 + const node = nodes[0];
174 + obj.nodeid = node._id; // Store the NodeID
175 + obj.meshid = node.meshid; // Store the MeshID
176 + obj.mtype = node.mtype; // Store the device group type
177 +
178 + // Check if we need to relay thru a different agent
179 + const mesh = parent.meshes[obj.meshid];
180 + if (mesh && mesh.relayid) {
181 + obj.relaynodeid = mesh.relayid;
182 + obj.tcpaddr = node.host;
183 +
184 + // Check if we have rights to the relayid device, does nothing if a relay is not used
185 + checkRelayRights(parent, domain, obj.cookie.userid, obj.relaynodeid, function (allowed) {
186 + if (obj.cookie == null) return; // obj has been cleaned up, just exit.
187 + if (allowed !== true) { parent.parent.debug('relay', 'TCP: Attempt to use un-authorized relay'); obj.close(); return; }
188 +
189 + // Re-encode a cookie with a device relay
190 + const cookieContent = { userid: obj.cookie.userid, domainid: obj.cookie.domainid, nodeid: mesh.relayid, tcpaddr: node.host, tcpport: obj.cookie.tcpport };
191 + obj.xcookie = parent.parent.encodeCookie(cookieContent, parent.parent.loginCookieEncryptionKey);
192 + });
193 + } else {
194 + obj.xcookie = req.query.auth;
195 + }
196 + });
197 +
198 + return obj;
199 +};
200 +
201 +
202 // Construct a MSTSC Relay object, called upon connection
203 // This implementation does not have TLS support
204 // This is a bit of a hack as we are going to run the RDP connection thru a loopback connection.
meshrelay.js
+2
@@ -43,6 +43,8 @@ const MESHRIGHT_ADMIN = 0xFFFFFFFF;
43 // 10 = Web-RDP
44 // 11 = Web-SSH
45 // 12 = Web-VNC
46 +// 13 = Web-SSH-Files
47 +// 14 = Web-TCP
48 // 100 = Intel AMT WSMAN
49 // 101 = Intel AMT Redirection
50 // 200 = Messenger
views/default.handlebars
+26 -1
@@ -1449,6 +1449,7 @@
1449 var features = parseInt('{{{features}}}');
1450 var features2 = parseInt('{{{features2}}}');
1451 var sessionTime = parseInt('{{{sessiontime}}}');
1452 + var webRelayPort = parseInt('{{{webRelayPort}}}');
1453 var sessionRefreshTimer = null;
1454 var domain = '{{{domain}}}';
1455 var domainUrl = '{{{domainurl}}}';
@@ -2737,7 +2738,7 @@
2738 if (message.name != null) { url += ('&name=' + encodeURIComponentEx(message.name)); }
2739 if (message.ip != null) { url += ('&remoteip=' + message.ip); }
2740 url += ('&appid=' + message.protocol + '&autoexit=1'); // Protocol: 0 = Custom, 1 = HTTP, 2 = HTTPS, 3 = RDP, 4 = PuTTY, 5 = WinSCP, 6 = MCRDesktop, 7 = MCRFiles
2740 - console.log(url);
2741 + //console.log(url);
2742 downloadFile(url, '');
2743 } else if (message.tag == 'novnc') {
2744 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 @@
4574
4575 // RDP link, show this link only of the remote machine is Windows.
4576 if ((((node.conn & 1) != 0) || (node.mtype == 3)) && (node.agent) && ((meshrights & 8) != 0) && (node.agent.id != 14)) {
4577 + if (webRelayPort != 0) {
4578 + x += '<a href=# onclick=p10WebRouter("' + node._id + '",1,80)>' + "HTTP" + '</a>&nbsp;';
4579 + //x += '<a href=# onclick=p10WebRouter("' + node._id + '",2,443)>' + "HTTPS" + '</a>&nbsp;';
4580 + }
4581 if ((node.agent.id > 0) && (node.agent.id < 5)) {
4582 if (navigator.platform.toLowerCase() == 'win32') {
4583 if ((serverinfo.devicemeshrouterlinks == null) || (serverinfo.devicemeshrouterlinks.rdp != false)) {
@@ -7141,6 +7146,10 @@
7146
7147 // RDP link, show this link only of the remote machine is Windows.
7148 if ((((connectivity & 1) != 0) || (node.mtype == 3)) && (node.agent) && ((meshrights & 8) != 0)) {
7149 + if (webRelayPort != 0) {
7150 + x += '<a href=# onclick=p10WebRouter("' + node._id + '",1,80)>' + "HTTP" + '</a>&nbsp;';
7151 + //x += '<a href=# onclick=p10WebRouter("' + node._id + '",2,443)>' + "HTTPS" + '</a>&nbsp;';
7152 + }
7153 if ((node.agent.id > 0) && (node.agent.id < 5)) {
7154 if (navigator.platform.toLowerCase() == 'win32') {
7155 if ((serverinfo.devicemeshrouterlinks == null) || (serverinfo.devicemeshrouterlinks.rdp != false)) {
@@ -8063,6 +8072,22 @@
8072 meshserver.send({ action: 'removedevices', nodeids: [ nodeid ] });
8073 }
8074
8075 + function p10WebRouter(nodeid, protocol, port, addr) {
8076 + var relayid = null;
8077 + var node = getNodeFromId(nodeid);
8078 + if (node.mtype == 3) { // Setup device relay if needed
8079 + var mesh = meshes[node.meshid];
8080 + if (mesh && mesh.relayid) { relayid = mesh.relayid; addr = node.host; }
8081 + }
8082 + var servername = serverinfo.name;
8083 + 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.
8084 + var url = 'https://' + servername + ':' + webRelayPort + '/control-redirect.ashx?n=' + nodeid + '&p=' + port + '&appid=' + protocol; // Protocol: 1 = HTTP, 2 = HTTPS
8085 + if (addr != null) { url += '&addr=' + addr; }
8086 + if (relayid != null) { url += '&relayid=' + relayid; }
8087 + safeNewWindow(url, 'WebRelay');
8088 + return false;
8089 + }
8090 +
8091 function p10MCRouter(nodeid, protocol, port, addr, localport) {
8092 var node = getNodeFromId(nodeid);
8093 var mesh = meshes[node.meshid];
webrelayserver.js
+67 -1
@@ -24,11 +24,35 @@ module.exports.CreateWebRelayServer = function (parent, db, args, certificates,
24 obj.net = require('net');
25 obj.app = obj.express();
26 obj.webRelayServer = null;
27 - obj.port = null;
27 + obj.port = 0;
28 + obj.relayTunnels = {} // RelayID --> Web Tunnel
29 const constants = (require('crypto').constants ? require('crypto').constants : require('constants')); // require('constants') is deprecated in Node 11.10, use require('crypto').constants instead.
30 var tlsSessionStore = {}; // Store TLS session information for quick resume.
31 var tlsSessionStoreCount = 0; // Number of cached TLS session information in store.
32
33 + if (args.trustedproxy) {
34 + // Reverse proxy should add the "X-Forwarded-*" headers
35 + try {
36 + obj.app.set('trust proxy', args.trustedproxy);
37 + } catch (ex) {
38 + // If there is an error, try to resolve the string
39 + if ((args.trustedproxy.length == 1) && (typeof args.trustedproxy[0] == 'string')) {
40 + require('dns').lookup(args.trustedproxy[0], function (err, address, family) { if (err == null) { obj.app.set('trust proxy', address); args.trustedproxy = [address]; } });
41 + }
42 + }
43 + }
44 + else if (typeof args.tlsoffload == 'object') {
45 + // Reverse proxy should add the "X-Forwarded-*" headers
46 + try {
47 + obj.app.set('trust proxy', args.tlsoffload);
48 + } catch (ex) {
49 + // If there is an error, try to resolve the string
50 + if ((Array.isArray(args.tlsoffload)) && (args.tlsoffload.length == 1) && (typeof args.tlsoffload[0] == 'string')) {
51 + require('dns').lookup(args.tlsoffload[0], function (err, address, family) { if (err == null) { obj.app.set('trust proxy', address); args.tlsoffload = [address]; } });
52 + }
53 + }
54 + }
55 +
56 // Add HTTP security headers to all responses
57 obj.app.use(function (req, res, next) {
58 parent.debug('webrequest', req.url + ' (RelayServer)');
@@ -41,9 +65,50 @@ module.exports.CreateWebRelayServer = function (parent, db, args, certificates,
65 'X-Content-Type-Options': 'nosniff',
66 'Content-Security-Policy': "default-src 'none'; style-src 'self' 'unsafe-inline';"
67 });
68 +
69 + // Set the real IP address of the request
70 + // If a trusted reverse-proxy is sending us the remote IP address, use it.
71 + var ipex = '0.0.0.0', xforwardedhost = req.headers.host;
72 + if (typeof req.connection.remoteAddress == 'string') { ipex = (req.connection.remoteAddress.startsWith('::ffff:')) ? req.connection.remoteAddress.substring(7) : req.connection.remoteAddress; }
73 + if (
74 + (args.trustedproxy === true) || (args.tlsoffload === true) ||
75 + ((typeof args.trustedproxy == 'object') && (isIPMatch(ipex, args.trustedproxy))) ||
76 + ((typeof args.tlsoffload == 'object') && (isIPMatch(ipex, args.tlsoffload)))
77 + ) {
78 + // Get client IP
79 + if (req.headers['cf-connecting-ip']) { // Use CloudFlare IP address if present
80 + req.clientIp = req.headers['cf-connecting-ip'].split(',')[0].trim();
81 + } else if (req.headers['x-forwarded-for']) {
82 + req.clientIp = req.headers['x-forwarded-for'].split(',')[0].trim();
83 + } else if (req.headers['x-real-ip']) {
84 + req.clientIp = req.headers['x-real-ip'].split(',')[0].trim();
85 + } else {
86 + req.clientIp = ipex;
87 + }
88 +
89 + // 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.
90 + const clientIpSplit = req.clientIp.split(':');
91 + if (clientIpSplit.length == 2) { req.clientIp = clientIpSplit[0]; }
92 +
93 + // Get server host
94 + 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.
95 + } else {
96 + req.clientIp = ipex;
97 + }
98 +
99 return next();
100 });
101
102 + // This is the magic URL that will setup the relay session
103 + obj.app.get('/control-redirect.ashx', function (req, res) {
104 + res.set({ 'Cache-Control': 'no-store' });
105 + parent.debug('web', 'webRelaySetup');
106 +
107 + console.log('req.query', req.query);
108 +
109 + res.redirect('/');
110 + });
111 +
112 // Start the server, only after users and meshes are loaded from the database.
113 if (args.tlsoffload) {
114 // Setup the HTTP server without TLS
@@ -86,6 +151,7 @@ module.exports.CreateWebRelayServer = function (parent, db, args, certificates,
151 obj.parent.updateServerState('http-relay-port', port);
152 if (args.aliasport != null) { obj.parent.updateServerState('http-relay-aliasport', args.aliasport); }
153 }
154 + obj.port = port;
155 }
156
157 CheckListenPort(args.relayport, args.relayportbind, StartWebRelayServer);
webserver.js
+11 -2
@@ -2858,7 +2858,8 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF
2858 footer: (domain.footer == null) ? '' : domain.footer,
2859 webstate: encodeURIComponent(webstate).replace(/'/g, '%27'),
2860 amtscanoptions: amtscanoptions,
2861 - pluginHandler: (parent.pluginHandler == null) ? 'null' : parent.pluginHandler.prepExports()
2861 + pluginHandler: (parent.pluginHandler == null) ? 'null' : parent.pluginHandler.prepExports(),
2862 + webRelayPort: ((parent.webrelayserver != null) ? parent.webrelayserver.port : 0)
2863 }, dbGetFunc.req, domain), user);
2864 }
2865 xdbGetFunc.req = req;
@@ -5846,11 +5847,19 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF
5847 var selfurl = ' wss://' + req.headers.host;
5848 if ((xforwardedhost != null) && (xforwardedhost != req.headers.host)) { selfurl += ' wss://' + xforwardedhost; }
5849 const extraScriptSrc = (parent.config.settings.extrascriptsrc != null) ? (' ' + parent.config.settings.extrascriptsrc) : '';
5850 +
5851 + // If the web relay port is enabled, allow the web page to redirect to it
5852 + var extraFrameSrc = '';
5853 + if ((parent.webrelayserver != null) && (parent.webrelayserver.port != 0)) {
5854 + extraFrameSrc = ' https://' + req.headers.host + ':' + parent.webrelayserver.port;
5855 + if ((xforwardedhost != null) && (xforwardedhost != req.headers.host)) { extraFrameSrc += ' https://' + xforwardedhost + ':' + parent.webrelayserver.port; }
5856 + }
5857 +
5858 const headers = {
5859 'Referrer-Policy': 'no-referrer',
5860 'X-XSS-Protection': '1; mode=block',
5861 'X-Content-Type-Options': 'nosniff',
5853 - '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'"
5862 + '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'"
5863 };
5864 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
5865 if ((parent.config.settings.allowframing !== true) && (typeof parent.config.settings.allowframing !== 'string')) { headers['X-Frame-Options'] = 'sameorigin'; }