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> ';
4579
+ //x += '<a href=# onclick=p10WebRouter("' + node._id + '",2,443)>' + "HTTPS" + '</a> ';
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> ';
7151
+ //x += '<a href=# onclick=p10WebRouter("' + node._id + '",2,443)>' + "HTTPS" + '</a> ';
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'; }