| 1 | /** |
| 2 | * @description Meshcentral web relay server |
| 3 | * @author Ylian Saint-Hilaire |
| 4 | * @copyright Intel Corporation 2018-2022 |
| 5 | * @license Apache-2.0 |
| 6 | * @version v0.0.1 |
| 7 | */ |
| 8 | |
| 9 | /*jslint node: true */ |
| 10 | /*jshint node: true */ |
| 11 | /*jshint strict:false */ |
| 12 | /*jshint -W097 */ |
| 13 | /*jshint esversion: 6 */ |
| 14 | "use strict"; |
| 15 | |
| 16 | // Construct a HTTP redirection web server object |
| 17 | module.exports.CreateWebRelayServer = function (parent, db, args, certificates, func) { |
| 18 | var obj = {}; |
| 19 | obj.parent = parent; |
| 20 | obj.db = db; |
| 21 | obj.express = require('express'); |
| 22 | obj.expressWs = null; |
| 23 | obj.tlsServer = null; |
| 24 | obj.net = require('net'); |
| 25 | obj.app = obj.express(); |
| 26 | obj.app.disable('x-powered-by'); |
| 27 | obj.webRelayServer = null; |
| 28 | obj.port = 0; |
| 29 | obj.cleanupTimer = null; |
| 30 | var relaySessions = {} // RelayID --> Web Mutli-Tunnel |
| 31 | const constants = (require('crypto').constants ? require('crypto').constants : require('constants')); // require('constants') is deprecated in Node 11.10, use require('crypto').constants instead. |
| 32 | var tlsSessionStore = {}; // Store TLS session information for quick resume. |
| 33 | var tlsSessionStoreCount = 0; // Number of cached TLS session information in store. |
| 34 | |
| 35 | function serverStart() { |
| 36 | // Setup CrowdSec bouncer middleware if needed |
| 37 | if (parent.crowdsecMiddleware != null) { obj.app.use(parent.crowdsecMiddleware); } |
| 38 | |
| 39 | if (args.trustedproxy) { |
| 40 | // Reverse proxy should add the "X-Forwarded-*" headers |
| 41 | try { |
| 42 | obj.app.set('trust proxy', args.trustedproxy); |
| 43 | } catch (ex) { |
| 44 | // If there is an error, try to resolve the string |
| 45 | if ((args.trustedproxy.length == 1) && (typeof args.trustedproxy[0] == 'string')) { |
| 46 | require('dns').lookup(args.trustedproxy[0], function (err, address, family) { if (err == null) { obj.app.set('trust proxy', address); args.trustedproxy = [address]; } }); |
| 47 | } |
| 48 | } |
| 49 | } |
| 50 | else if (typeof args.tlsoffload == 'object') { |
| 51 | // Reverse proxy should add the "X-Forwarded-*" headers |
| 52 | try { |
| 53 | obj.app.set('trust proxy', args.tlsoffload); |
| 54 | } catch (ex) { |
| 55 | // If there is an error, try to resolve the string |
| 56 | if ((Array.isArray(args.tlsoffload)) && (args.tlsoffload.length == 1) && (typeof args.tlsoffload[0] == 'string')) { |
| 57 | require('dns').lookup(args.tlsoffload[0], function (err, address, family) { if (err == null) { obj.app.set('trust proxy', address); args.tlsoffload = [address]; } }); |
| 58 | } |
| 59 | } |
| 60 | } |
| 61 | |
| 62 | // Setup a keygrip instance with higher default security, default hash is SHA1, we want to bump that up with SHA384 |
| 63 | // If multiple instances of this server are behind a load-balancer, this secret must be the same for all instances |
| 64 | // If args.sessionkey is a string, use it as a single key, but args.sessionkey can also be used as an array of keys. |
| 65 | const keygrip = require('keygrip')((typeof args.sessionkey == 'string') ? [args.sessionkey] : args.sessionkey, 'sha384', 'base64'); |
| 66 | |
| 67 | // Watch for device share removal |
| 68 | parent.AddEventDispatch(['server-shareremove'], obj); |
| 69 | obj.HandleEvent = function (source, event, ids, id) { |
| 70 | if (event.action == 'removedDeviceShare') { |
| 71 | for (var relaySessionId in relaySessions) { |
| 72 | // A share was removed that matches an active session, close the session. |
| 73 | if (relaySessions[relaySessionId].xpublicid === event.publicid) { relaySessions[relaySessionId].close(); } |
| 74 | } |
| 75 | } |
| 76 | } |
| 77 | |
| 78 | // Setup cookie session |
| 79 | const sessionOptions = { |
| 80 | name: 'xid', // Recommended security practice to not use the default cookie name |
| 81 | httpOnly: true, |
| 82 | keys: keygrip, |
| 83 | secure: (args.tlsoffload == null), // Use this cookie only over TLS (Check this: https://expressjs.com/en/guide/behind-proxies.html) |
| 84 | sameSite: (args.sessionsamesite ? args.sessionsamesite : 'lax') |
| 85 | } |
| 86 | if (args.sessiontime != null) { sessionOptions.maxAge = (args.sessiontime * 60000); } // sessiontime is minutes |
| 87 | obj.app.use(require('cookie-session')(sessionOptions)); |
| 88 | obj.app.use(function(request, response, next) { // Patch for passport 0.6.0 - https://github.com/jaredhanson/passport/issues/904 |
| 89 | if (request.session && !request.session.regenerate) { |
| 90 | request.session.regenerate = function (cb) { |
| 91 | cb() |
| 92 | } |
| 93 | } |
| 94 | if (request.session && !request.session.save) { |
| 95 | request.session.save = function (cb) { |
| 96 | cb() |
| 97 | } |
| 98 | } |
| 99 | next() |
| 100 | }); |
| 101 | |
| 102 | // Add HTTP security headers to all responses |
| 103 | obj.app.use(function (req, res, next) { |
| 104 | parent.debug('webrelay', req.url); |
| 105 | res.removeHeader('X-Powered-By'); |
| 106 | res.set({ |
| 107 | 'strict-transport-security': 'max-age=60000; includeSubDomains', |
| 108 | 'Referrer-Policy': 'no-referrer', |
| 109 | 'x-frame-options': 'SAMEORIGIN', |
| 110 | 'X-XSS-Protection': '1; mode=block', |
| 111 | 'X-Content-Type-Options': 'nosniff', |
| 112 | 'Content-Security-Policy': "default-src 'self'; style-src 'self' 'unsafe-inline';" |
| 113 | }); |
| 114 | |
| 115 | // Set the real IP address of the request |
| 116 | // If a trusted reverse-proxy is sending us the remote IP address, use it. |
| 117 | var ipex = '0.0.0.0', xforwardedhost = req.headers.host; |
| 118 | if (typeof req.connection.remoteAddress == 'string') { ipex = (req.connection.remoteAddress.startsWith('::ffff:')) ? req.connection.remoteAddress.substring(7) : req.connection.remoteAddress; } |
| 119 | if ( |
| 120 | (args.trustedproxy === true) || (args.tlsoffload === true) || |
| 121 | ((typeof args.trustedproxy == 'object') && (isIPMatch(ipex, args.trustedproxy))) || |
| 122 | ((typeof args.tlsoffload == 'object') && (isIPMatch(ipex, args.tlsoffload))) |
| 123 | ) { |
| 124 | // Get client IP |
| 125 | if (req.headers['cf-connecting-ip']) { // Use CloudFlare IP address if present |
| 126 | req.clientIp = req.headers['cf-connecting-ip'].split(',')[0].trim(); |
| 127 | } else if (req.headers['x-forwarded-for']) { |
| 128 | req.clientIp = req.headers['x-forwarded-for'].split(',')[0].trim(); |
| 129 | } else if (req.headers['x-real-ip']) { |
| 130 | req.clientIp = req.headers['x-real-ip'].split(',')[0].trim(); |
| 131 | } else { |
| 132 | req.clientIp = ipex; |
| 133 | } |
| 134 | |
| 135 | // 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. |
| 136 | const clientIpSplit = req.clientIp.split(':'); |
| 137 | if (clientIpSplit.length == 2) { req.clientIp = clientIpSplit[0]; } |
| 138 | |
| 139 | // Get server host |
| 140 | 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. |
| 141 | } else { |
| 142 | req.clientIp = ipex; |
| 143 | } |
| 144 | |
| 145 | // If this is a session start or a websocket, have the application handle this |
| 146 | if ((req.headers.upgrade == 'websocket') || (req.url.startsWith('/control-redirect.ashx?n='))) { |
| 147 | return next(); |
| 148 | } else { |
| 149 | // If this is a normal request (GET, POST, etc) handle it here |
| 150 | var webSessionId = null; |
| 151 | if ((req.session.userid != null) && (req.session.x != null)) { webSessionId = req.session.userid + '/' + req.session.x; } |
| 152 | else if (req.session.z != null) { webSessionId = req.session.z; } |
| 153 | if ((webSessionId != null) && (parent.webserver.destroyedSessions[webSessionId] == null)) { |
| 154 | var relaySession = relaySessions[webSessionId]; |
| 155 | if (relaySession != null) { |
| 156 | // The web relay session is valid, use it |
| 157 | relaySession.handleRequest(req, res); |
| 158 | } else { |
| 159 | // No web relay ession with this relay identifier, close the HTTP request. |
| 160 | res.end(); |
| 161 | } |
| 162 | } else { |
| 163 | // The user is not logged in or does not have a relay identifier, close the HTTP request. |
| 164 | res.end(); |
| 165 | } |
| 166 | } |
| 167 | }); |
| 168 | |
| 169 | // Start the server, only after users and meshes are loaded from the database. |
| 170 | if (args.tlsoffload) { |
| 171 | // Setup the HTTP server without TLS |
| 172 | obj.expressWs = require('express-ws')(obj.app, null, { wsOptions: { perMessageDeflate: (args.wscompression === true) } }); |
| 173 | } else { |
| 174 | // Setup the HTTP server with TLS, use only TLS 1.2 and higher with perfect forward secrecy (PFS). |
| 175 | const tlsOptions = { cert: certificates.web.cert, key: certificates.web.key, ca: certificates.web.ca, rejectUnauthorized: true, ciphers: "HIGH:TLS_AES_256_GCM_SHA384:TLS_AES_128_GCM_SHA256:TLS_AES_128_CCM_8_SHA256:TLS_AES_128_CCM_SHA256:TLS_CHACHA20_POLY1305_SHA256", secureOptions: constants.SSL_OP_NO_SSLv2 | constants.SSL_OP_NO_SSLv3 | constants.SSL_OP_NO_COMPRESSION | constants.SSL_OP_CIPHER_SERVER_PREFERENCE | constants.SSL_OP_NO_TLSv1 | constants.SSL_OP_NO_TLSv1_1 }; |
| 176 | obj.tlsServer = require('https').createServer(tlsOptions, obj.app); |
| 177 | obj.tlsServer.on('secureConnection', function () { /*console.log('tlsServer secureConnection');*/ }); |
| 178 | obj.tlsServer.on('error', function (err) { console.log('tlsServer error', err); }); |
| 179 | obj.tlsServer.on('newSession', function (id, data, cb) { if (tlsSessionStoreCount > 1000) { tlsSessionStoreCount = 0; tlsSessionStore = {}; } tlsSessionStore[id.toString('hex')] = data; tlsSessionStoreCount++; cb(); }); |
| 180 | obj.tlsServer.on('resumeSession', function (id, cb) { cb(null, tlsSessionStore[id.toString('hex')] || null); }); |
| 181 | obj.expressWs = require('express-ws')(obj.app, obj.tlsServer, { wsOptions: { perMessageDeflate: (args.wscompression === true) } }); |
| 182 | } |
| 183 | |
| 184 | // Handle incoming web socket calls |
| 185 | obj.app.ws('/*', function (ws, req) { |
| 186 | var webSessionId = null; |
| 187 | if ((req.session.userid != null) && (req.session.x != null)) { webSessionId = req.session.userid + '/' + req.session.x; } |
| 188 | else if (req.session.z != null) { webSessionId = req.session.z; } |
| 189 | if ((webSessionId != null) && (parent.webserver.destroyedSessions[webSessionId] == null)) { |
| 190 | var relaySession = relaySessions[webSessionId]; |
| 191 | if (relaySession != null) { |
| 192 | // The multi-tunnel session is valid, use it |
| 193 | relaySession.handleWebSocket(ws, req); |
| 194 | } else { |
| 195 | // No multi-tunnel session with this relay identifier, close the websocket. |
| 196 | ws.close(); |
| 197 | } |
| 198 | } else { |
| 199 | // The user is not logged in or does not have a relay identifier, close the websocket. |
| 200 | ws.close(); |
| 201 | } |
| 202 | }); |
| 203 | |
| 204 | // This is the magic URL that will setup the relay session |
| 205 | obj.app.get('/control-redirect.ashx', function (req, res) { |
| 206 | res.set({ 'Cache-Control': 'no-store' }); |
| 207 | parent.debug('webrelay', 'webRelaySetup'); |
| 208 | |
| 209 | // Decode the relay cookie |
| 210 | if (req.query.c == null) { res.sendStatus(404); return; } |
| 211 | |
| 212 | // Decode and check if this relay cookie is valid |
| 213 | var userid, domainid, domain, nodeid, addr, port, appid, webSessionId, expire, publicid; |
| 214 | const urlCookie = obj.parent.decodeCookie(req.query.c, parent.loginCookieEncryptionKey, 32); // Allow cookies up to 32 minutes old. The web page will renew this cookie every 30 minutes. |
| 215 | if (urlCookie == null) { res.sendStatus(404); return; } |
| 216 | |
| 217 | // Decode the incoming cookie |
| 218 | if ((urlCookie.ruserid != null) && (urlCookie.x != null)) { |
| 219 | if (parent.webserver.destroyedSessions[urlCookie.ruserid + '/' + urlCookie.x] != null) { res.sendStatus(404); return; } |
| 220 | |
| 221 | // This is a standard user, figure out what our web relay will be. |
| 222 | if (req.session.x != urlCookie.x) { req.session.x = urlCookie.x; } // Set the sessionid if missing |
| 223 | if (req.session.userid != urlCookie.ruserid) { req.session.userid = urlCookie.ruserid; } // Set the session userid if missing |
| 224 | if (req.session.z) { delete req.session.z; } // Clear the web relay guest session |
| 225 | userid = req.session.userid; |
| 226 | domainid = userid.split('/')[1]; |
| 227 | domain = parent.config.domains[domainid]; |
| 228 | nodeid = ((req.query.relayid != null) ? req.query.relayid : req.query.n); |
| 229 | addr = (req.query.addr != null) ? req.query.addr : '127.0.0.1'; |
| 230 | port = parseInt(req.query.p); |
| 231 | appid = parseInt(req.query.appid); |
| 232 | webSessionId = req.session.userid + '/' + req.session.x; |
| 233 | |
| 234 | // Check that all the required arguments are present |
| 235 | if ((req.session.userid == null) || (req.session.x == null) || (req.query.n == null) || (req.query.p == null) || (parent.webserver.destroyedSessions[webSessionId] != null) || ((req.query.appid != 1) && (req.query.appid != 2))) { res.redirect('/'); return; } |
| 236 | } else if (urlCookie.r == 8) { |
| 237 | // This is a guest user, figure out what our web relay will be. |
| 238 | userid = urlCookie.userid; |
| 239 | domainid = userid.split('/')[1]; |
| 240 | domain = parent.config.domains[domainid]; |
| 241 | nodeid = urlCookie.nid; |
| 242 | addr = (urlCookie.addr != null) ? urlCookie.addr : '127.0.0.1'; |
| 243 | port = urlCookie.port; |
| 244 | appid = (urlCookie.p == 16) ? 2 : 1; // appid: 1 = HTTP, 2 = HTTPS |
| 245 | webSessionId = userid + '/' + urlCookie.pid; |
| 246 | publicid = urlCookie.pid; |
| 247 | if (req.session.x) { delete req.session.x; } // Clear the web relay sessionid |
| 248 | if (req.session.userid) { delete req.session.userid; } // Clear the web relay userid |
| 249 | if (req.session.z != webSessionId) { req.session.z = webSessionId; } // Set the web relay guest session |
| 250 | expire = urlCookie.expire; |
| 251 | if ((expire != null) && (expire <= Date.now())) { parent.debug('webrelay', 'expired link'); res.sendStatus(404); return; } |
| 252 | } |
| 253 | |
| 254 | // No session identifier was setup, exit now |
| 255 | if (webSessionId == null) { res.sendStatus(404); return; } |
| 256 | |
| 257 | // Check to see if we already have a multi-relay session that matches exactly this device and port for this user |
| 258 | const xrelaySession = relaySessions[webSessionId]; |
| 259 | if ((xrelaySession != null) && (xrelaySession.domain.id == domain.id) && (xrelaySession.userid == userid) && (xrelaySession.nodeid == nodeid) && (xrelaySession.addr == addr) && (xrelaySession.port == port) && (xrelaySession.appid == appid)) { |
| 260 | // We found an exact match, we are all setup already, redirect to root |
| 261 | res.redirect('/'); |
| 262 | return; |
| 263 | } |
| 264 | |
| 265 | // Check that the user has rights to access this device |
| 266 | parent.webserver.GetNodeWithRights(domain, userid, nodeid, function (node, rights, visible) { |
| 267 | // If there is no remote control or relay rights, reject this web relay |
| 268 | if ((rights & 0x00200008) == 0) { res.sendStatus(404); return; } // MESHRIGHT_REMOTECONTROL or MESHRIGHT_RELAY |
| 269 | |
| 270 | // There is a relay session, but it's not correct, close it. |
| 271 | if (xrelaySession != null) { xrelaySession.close(); delete relaySessions[webSessionId]; } |
| 272 | |
| 273 | // Create a web relay session |
| 274 | const relaySession = require('./apprelays.js').CreateWebRelaySession(obj, db, req, args, domain, userid, nodeid, addr, port, appid, webSessionId, expire, node.mtype); |
| 275 | relaySession.xpublicid = publicid; |
| 276 | relaySession.onclose = function (sessionId) { |
| 277 | // Remove the relay session |
| 278 | delete relaySessions[sessionId]; |
| 279 | // If there are not more relay sessions, clear the cleanup timer |
| 280 | if ((Object.keys(relaySessions).length == 0) && (obj.cleanupTimer != null)) { clearInterval(obj.cleanupTimer); obj.cleanupTimer = null; } |
| 281 | } |
| 282 | |
| 283 | // Set the multi-tunnel session |
| 284 | relaySessions[webSessionId] = relaySession; |
| 285 | |
| 286 | // Setup the cleanup timer if needed |
| 287 | if (obj.cleanupTimer == null) { obj.cleanupTimer = setInterval(checkTimeout, 10000); } |
| 288 | |
| 289 | // Redirect to root |
| 290 | res.redirect('/'); |
| 291 | }); |
| 292 | }); |
| 293 | } |
| 294 | |
| 295 | // Check that everything is cleaned up |
| 296 | function checkTimeout() { |
| 297 | for (var i in relaySessions) { relaySessions[i].checkTimeout(); } |
| 298 | } |
| 299 | |
| 300 | // Find a free port starting with the specified one and going up. |
| 301 | function CheckListenPort(port, addr, func) { |
| 302 | var s = obj.net.createServer(function (socket) { }); |
| 303 | obj.webRelayServer = s.listen(port, addr, function () { s.close(function () { if (func) { func(port, addr); } }); }).on("error", function (err) { |
| 304 | if (args.exactports) { console.error("ERROR: MeshCentral HTTP relay server port " + port + " not available."); process.exit(); } |
| 305 | else { if (port < 65535) { CheckListenPort(port + 1, addr, func); } else { if (func) { func(0); } } } |
| 306 | }); |
| 307 | } |
| 308 | |
| 309 | // Start the ExpressJS web server, if the port is busy try the next one. |
| 310 | function StartWebRelayServer(port, addr) { |
| 311 | if (port == 0 || port == 65535) { return; } |
| 312 | if (obj.tlsServer != null) { |
| 313 | if (args.lanonly == true) { |
| 314 | obj.tcpServer = obj.tlsServer.listen(port, addr, function () { console.log('MeshCentral HTTPS relay server running on port ' + port + ((typeof args.relayaliasport == 'number') ? (', alias port ' + args.relayaliasport) : '') + '.'); }); |
| 315 | } else { |
| 316 | obj.tcpServer = obj.tlsServer.listen(port, addr, function () { console.log('MeshCentral HTTPS relay server running on ' + certificates.CommonName + ':' + port + ((typeof args.relayaliasport == 'number') ? (', alias port ' + args.relayaliasport) : '') + '.'); }); |
| 317 | obj.parent.updateServerState('servername', certificates.CommonName); |
| 318 | } |
| 319 | if (obj.parent.authlog) { obj.parent.authLog('https', 'Web relay server listening on ' + ((addr != null) ? addr : '0.0.0.0') + ' port ' + port + '.'); } |
| 320 | obj.parent.updateServerState('https-relay-port', port); |
| 321 | if (typeof args.relayaliasport == 'number') { obj.parent.updateServerState('https-relay-aliasport', args.relayaliasport); } |
| 322 | } else { |
| 323 | obj.tcpServer = obj.app.listen(port, addr, function () { console.log('MeshCentral HTTP relay server running on port ' + port + ((typeof args.relayaliasport == 'number') ? (', alias port ' + args.relayaliasport) : '') + '.'); }); |
| 324 | obj.parent.updateServerState('http-relay-port', port); |
| 325 | if (typeof args.relayaliasport == 'number') { obj.parent.updateServerState('http-relay-aliasport', args.relayaliasport); } |
| 326 | } |
| 327 | obj.port = port; |
| 328 | } |
| 329 | |
| 330 | function getRandomPassword() { return Buffer.from(require('crypto').randomBytes(9), 'binary').toString('base64').split('/').join('@'); } |
| 331 | |
| 332 | // Perform a IP match against a list |
| 333 | function isIPMatch(ip, matchList) { |
| 334 | const ipcheck = require('ipcheck'); |
| 335 | for (var i in matchList) { if (ipcheck.match(ip, matchList[i]) == true) return true; } |
| 336 | return false; |
| 337 | } |
| 338 | |
| 339 | // Start up the web relay server |
| 340 | serverStart(); |
| 341 | CheckListenPort(args.relayport, args.relayportbind, StartWebRelayServer); |
| 342 | |
| 343 | return obj; |
| 344 | }; |