diff --git a/.vscode/settings.json b/.vscode/settings.json index 596995f89f..241f056f2f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -339,6 +339,9 @@ "isaml", "Jitsi", "jumpcloud", + "keyagents", + "keyagentsgrace", + "keyagenttimeout", "keyfile", "keygrip", "keyid", diff --git a/meshagent.js b/meshagent.js index 68d78790ea..8e9b766613 100644 --- a/meshagent.js +++ b/meshagent.js @@ -15,7 +15,7 @@ "use strict"; // Construct a MeshAgent object, called upon connection -module.exports.CreateMeshAgent = function (parent, db, ws, req, args, domain) { +module.exports.CreateMeshAgent = function (parent, db, ws, req, args, domain, key) { const forge = parent.parent.certificateOperations.forge; const common = parent.parent.common; parent.agentStats.createMeshAgentCount++; @@ -29,6 +29,7 @@ module.exports.CreateMeshAgent = function (parent, db, ws, req, args, domain) { obj.remoteaddr = req.clientIp; obj.remoteaddrport = obj.remoteaddr + ':' + ws._socket.remotePort; obj.nonce = parent.crypto.randomBytes(48).toString('binary'); + obj.key = key //ws._socket.setKeepAlive(true, 240000); // Set TCP keep alive, 4 minutes if (args.agentidletimeout != 0) { ws._socket.setTimeout(args.agentidletimeout, function () { obj.close(1); }); } // Inactivity timeout of 2:30 minutes, by default agent will WebSocket ping every 2 minutes and server will pong back. //obj.nodeid = null; @@ -519,6 +520,19 @@ module.exports.CreateMeshAgent = function (parent, db, ws, req, args, domain) { // Check the agent signature if we can if (obj.agentnonce == null) { obj.unauthsign = msg.substring(4 + certlen); } else { + if (obj.key) { + let _nodekey = 'node/' + domain.id + '/' + obj.unauth.nodeid; + if (!obj.key.nodeid && (!obj.key.expire || (+Date.now() < obj.key.expire))) { + obj.key.nodeid = _nodekey; + obj.key.domain = domain.id + db.Set(obj.key) + } else if (obj.key.nodeid !== _nodekey) { + parent.agentStats.agentBadSignature3Count++; + parent.setAgentIssue(obj, "BadSignature3"); + parent.parent.debug('agent', 'Agent connected as a node that does not match its key, holding connection (' + obj.remoteaddrport + ').'); + console.log('Agent connected as a node that does not match its key, holding connection (' + obj.remoteaddrport + ').'); return; + } + } if (processAgentSignature(msg.substring(4 + certlen)) == false) { parent.agentStats.agentBadSignature2Count++; parent.setAgentIssue(obj, "BadSignature2"); @@ -1107,6 +1121,33 @@ module.exports.CreateMeshAgent = function (parent, db, ws, req, args, domain) { if (domain.agentselfguestsharing) { serverInfo.agentSelfGuestSharing = true; } obj.send(JSON.stringify(serverInfo)); + if ((domain.keyagents || parent.parent.config.settings.keyagents) && obj.key == undefined) { + let keyagentsgrace = domain.keyagentsgrace || parent.parent.config.settings.keyagentsgrace || 0; + if (keyagentsgrace !== 0) { + keyagentsgrace = +(Date.parse(keyagentsgrace)) + } + // If keyagentsgrace is not right here, we would have errored out long before this, but might as well handle it here as well. + if (+(new Date()) < keyagentsgrace) { + if (args.lanonly != true) { + let serverName = parent.getWebServerName(domain, req); + let httpsPort = ((args.aliasport == null) ? args.port : args.aliasport); // Use HTTPS alias port is specified + let xdomain = (domain.dns == null) ? domain.id : ''; + if (xdomain != '') xdomain += '/'; + let connectString = 'wss://' + serverName + ':' + httpsPort + '/' + xdomain + 'agent.ashx'; + let [_agentKey, _key] = parent.generateAgentKey(domain) + _agentKey.nodeid = obj.dbNodeKey + db.Set(_agentKey); + connectString += `?key=${_key.toString("hex")}`; + obj.key = _agentKey + obj.send(JSON.stringify({ action: 'msg', type: 'console', value: `msh set MeshServer ${connectString}`, + // msg expects a session ID, but we don't have one and msh command doesn't use it, so spoof it + sessionid: parent.crypto.randomBytes(16).toString("hex"), + // Force all rights + rights: 4294967295 })); + } + } + } + // Plug in handler if (parent.parent.pluginHandler != null) { parent.parent.pluginHandler.callHook('hook_agentCoreIsStable', obj, parent); diff --git a/meshcentral-config-schema.json b/meshcentral-config-schema.json index 89b361776f..98143a0da2 100644 --- a/meshcentral-config-schema.json +++ b/meshcentral-config-schema.json @@ -383,6 +383,21 @@ "default": false, "description": "When enabled, MeshCentral will block all downloads of MeshAgent including install scripts, if the user is not logged in" }, + "keyAgents": { + "type": "boolean", + "default": false, + "description": "When enabled, Meshcentral will key each agent downloaded to a specific node id, which is determined on first agent connection. If an agent attempts to connect without this key, or with a node id that does not match this key, it will be ignored. Deleting a device will remove its key, preventing that agent from connecting again in the future." + }, + "keyAgentsGrace": { + "type": "string", + "default": null, + "description": "Date to allow unkeyed connections. When set in conjunction with keyAgents, meshcentral will accept any agent connection until the date specified and attempt to automatically update the agent to a keyed agent. Standard javascript date format, described at https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date#date_time_string_format ." + }, + "keyAgentTimeout": { + "type": "integer", + "default": 60, + "description": "Timeout in minutes for which an agentKey will be valid. If no device connects by this time, that agentkey will be deactivated. If set to null, keys will never timeout." + }, "ignoreAgentHashCheck": { "type": [ "boolean", @@ -1742,6 +1757,21 @@ "default": false, "description": "When enabled, MeshCentral will block all downloads of MeshAgent including install scripts, if the user is not logged in" }, + "keyAgents": { + "type": "boolean", + "default": false, + "description": "When enabled, Meshcentral will key each agent downloaded to a specific node id, which is determined on first agent connection. If an agent attempts to connect without this key, or with a node id that does not match this key, it will be ignored. Deleting a device will remove its key, preventing that agent from connecting again in the future." + }, + "keyAgentsGrace": { + "type": "string", + "default": null, + "description": "Date to allow unkeyed connections. When set in conjunction with keyAgents, meshcentral will accept any agent connection until the date specified and attempt to automatically update the agent to a keyed agent. Standard javascript date format, described at https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date#date_time_string_format ." + }, + "keyAgentTimeout": { + "type": "integer", + "default": 60, + "description": "Timeout in minutes for which an agentKey will be valid. If no device connects by this time, that agentkey will be deactivated. If set to null, keys will never timeout." + }, "geoLocation": { "type": "boolean", "default": false, diff --git a/meshctrl.js b/meshctrl.js index f3186f4177..7016164c52 100644 --- a/meshctrl.js +++ b/meshctrl.js @@ -1106,7 +1106,7 @@ function displayConfigHelp() { } function performConfigOperations(args) { - var domainValues = ['title', 'title2', 'titlepicture', 'trustedcert', 'welcomepicture', 'welcometext', 'userquota', 'meshquota', 'newaccounts', 'usernameisemail', 'newaccountemaildomains', 'newaccountspass', 'newaccountsrights', 'geolocation', 'lockagentdownload', 'userconsentflags', 'Usersessionidletimeout', 'auth', 'ldapoptions', 'ldapusername', 'ldapuserbinarykey', 'ldapuseremail', 'footer', 'certurl', 'loginKey', 'userallowedip', 'agentallowedip', 'agentnoproxy', 'agentconfig', 'orphanagentuser', 'httpheaders', 'yubikey', 'passwordrequirements', 'limits', 'amtacmactivation', 'redirects', 'sessionrecording', 'hide']; + var domainValues = ['title', 'title2', 'titlepicture', 'trustedcert', 'welcomepicture', 'welcometext', 'userquota', 'meshquota', 'newaccounts', 'usernameisemail', 'newaccountemaildomains', 'newaccountspass', 'newaccountsrights', 'geolocation', 'lockagentdownload', 'keyagents', 'keyagentsgrace', "keyagenttimeout", 'userconsentflags', 'Usersessionidletimeout', 'auth', 'ldapoptions', 'ldapusername', 'ldapuserbinarykey', 'ldapuseremail', 'footer', 'certurl', 'loginKey', 'userallowedip', 'agentallowedip', 'agentnoproxy', 'agentconfig', 'orphanagentuser', 'httpheaders', 'yubikey', 'passwordrequirements', 'limits', 'amtacmactivation', 'redirects', 'sessionrecording', 'hide']; var domainObjectValues = ['ldapoptions', 'httpheaders', 'yubikey', 'passwordrequirements', 'limits', 'amtacmactivation', 'redirects', 'sessionrecording']; var domainArrayValues = ['newaccountemaildomains', 'newaccountsrights', 'loginkey', 'agentconfig']; var configChange = false; diff --git a/meshuser.js b/meshuser.js index 226c9dedd0..d8914ab397 100644 --- a/meshuser.js +++ b/meshuser.js @@ -520,7 +520,7 @@ module.exports.CreateMeshUser = function (parent, db, ws, req, args, domain, use if (agentstats.invalidJsonCount > 0) { errorCountersCount++; errorCounters.InvalidJSON = agentstats.invalidJsonCount; } if (agentstats.unknownAgentActionCount > 0) { errorCountersCount++; errorCounters.UnknownAction = agentstats.unknownAgentActionCount; } if (agentstats.agentBadWebCertHashCount > 0) { errorCountersCount++; errorCounters.BadWebCertificate = agentstats.agentBadWebCertHashCount; } - if ((agentstats.agentBadSignature1Count + agentstats.agentBadSignature2Count) > 0) { errorCountersCount++; errorCounters.BadSignature = (agentstats.agentBadSignature1Count + agentstats.agentBadSignature2Count); } + if ((agentstats.agentBadSignature1Count + agentstats.agentBadSignature2Count + agentstats.agentBadSignature3Count) > 0) { errorCountersCount++; errorCounters.BadSignature = (agentstats.agentBadSignature1Count + agentstats.agentBadSignature2Count + agentstats.agentBadSignature3Count); } if (agentstats.agentMaxSessionHoldCount > 0) { errorCountersCount++; errorCounters.MaxSessionsReached = agentstats.agentMaxSessionHoldCount; } if ((agentstats.invalidDomainMeshCount + agentstats.invalidDomainMesh2Count) > 0) { errorCountersCount++; errorCounters.UnknownDeviceGroup = (agentstats.invalidDomainMeshCount + agentstats.invalidDomainMesh2Count); } if ((agentstats.invalidMeshTypeCount + agentstats.invalidMeshType2Count) > 0) { errorCountersCount++; errorCounters.InvalidDeviceGroupType = (agentstats.invalidMeshTypeCount + agentstats.invalidMeshType2Count); } @@ -2794,6 +2794,13 @@ module.exports.CreateMeshUser = function (parent, db, ws, req, args, domain, use if ((nodes != null) && (nodes.length == 1)) { db.Remove('da' + nodes[0].daid); } // Remove diagnostic agent to real agent link db.Remove('ra' + node._id); // Remove real agent to diagnostic agent link }); + if (domain.keyagents || parent.parent.config.settings.keyagents) { + db.GetAllTypeNodeFiltered([nodeid], domain.id, 'agentkey', null, function (err, docs) { + for (let _doc of docs) { + db.Remove(_doc._id); + } + }) + } // Remove any user node links if (node.links != null) { diff --git a/webserver.js b/webserver.js index 51c07172f6..d32d82dc92 100644 --- a/webserver.js +++ b/webserver.js @@ -369,6 +369,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF agentBadWebCertHashCount: 0, agentBadSignature1Count: 0, agentBadSignature2Count: 0, + agentBadSignature3Count: 0, agentMaxSessionHoldCount: 0, invalidDomainMeshCount: 0, invalidMeshTypeCount: 0, @@ -5413,7 +5414,16 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF var meshsettings = ''; if (req.query.ac != '4') { // If MeshCentral Assistant Monitor Mode, DONT INCLUDE SERVER DETAILS! meshsettings += '\r\nMeshName=' + mesh.name + '\r\nMeshType=' + mesh.mtype + '\r\nMeshID=0x' + meshidhex + '\r\nServerID=' + serveridhex + '\r\n'; - if (obj.args.lanonly != true) { meshsettings += 'MeshServer=wss://' + serverName + ':' + httpsPort + '/' + xdomain + 'agent.ashx\r\n'; } else { + if (obj.args.lanonly != true) { + let connectString = 'wss://' + serverName + ':' + httpsPort + '/' + xdomain + 'agent.ashx'; + + if (domain.keyagents || obj.parent.config.settings.keyagents) { + let [_agentKey, _key] = obj.generateAgentKey(domain) + db.Set(_agentKey); + connectString += `?key=${_key.toString("hex")}`; + } + meshsettings += 'MeshServer=' + connectString + '\r\n'; + } else { meshsettings += 'MeshServer=local\r\n'; if ((obj.args.localdiscovery != null) && (typeof obj.args.localdiscovery.key == 'string') && (obj.args.localdiscovery.key.length > 0)) { meshsettings += 'DiscoveryKey=' + obj.args.localdiscovery.key + '\r\n'; } } @@ -5848,7 +5858,15 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF var httpsPort = ((obj.args.aliasport == null) ? obj.args.port : obj.args.aliasport); // Use HTTPS alias port is specified if (obj.args.agentport != null) { httpsPort = obj.args.agentport; } // If an agent only port is enabled, use that. if (obj.args.agentaliasport != null) { httpsPort = obj.args.agentaliasport; } // If an agent alias port is specified, use that. - if (obj.args.lanonly != true) { meshsettings += 'MeshServer=wss://' + serverName + ':' + httpsPort + '/' + xdomain + 'agent.ashx\r\n'; } else { + if (obj.args.lanonly != true) { + let connectString = 'wss://' + serverName + ':' + httpsPort + '/' + xdomain + 'agent.ashx'; + if (domain.keyagents || obj.parent.config.settings.keyagents) { + let [_agentKey, _key] = obj.generateAgentKey(domain) + db.Set(_agentKey); + connectString += `?key=${_key.toString("hex")}`; + } + meshsettings += 'MeshServer=' + connectString + '\r\n'; + } else { meshsettings += 'MeshServer=local\r\n'; if ((obj.args.localdiscovery != null) && (typeof obj.args.localdiscovery.key == 'string') && (obj.args.localdiscovery.key.length > 0)) { meshsettings += 'DiscoveryKey=' + obj.args.localdiscovery.key + '\r\n'; } } @@ -5995,7 +6013,15 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF var httpsPort = ((obj.args.aliasport == null) ? obj.args.port : obj.args.aliasport); // Use HTTPS alias port is specified if (obj.args.agentport != null) { httpsPort = obj.args.agentport; } // If an agent only port is enabled, use that. if (obj.args.agentaliasport != null) { httpsPort = obj.args.agentaliasport; } // If an agent alias port is specified, use that. - if (obj.args.lanonly != true) { meshsettings += 'MeshServer=wss://' + serverName + ':' + httpsPort + '/' + xdomain + 'agent.ashx\r\n'; } else { + if (obj.args.lanonly != true) { + let connectString = 'wss://' + serverName + ':' + httpsPort + '/' + xdomain + 'agent.ashx'; + if (domain.keyagents || obj.parent.config.settings.keyagents) { + let [_agentKey, _key] = obj.generateAgentKey(domain) + db.Set(_agentKey); + connectString += `?key=${_key.toString("hex")}`; + } + meshsettings += 'MeshServer=' + connectString + '\r\n'; + } else { meshsettings += 'MeshServer=local\r\n'; if ((obj.args.localdiscovery != null) && (typeof obj.args.localdiscovery.key == 'string') && (obj.args.localdiscovery.key.length > 0)) { meshsettings += 'DiscoveryKey=' + obj.args.localdiscovery.key + '\r\n'; } } @@ -6880,8 +6906,36 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF var domain = checkAgentIpAddress(ws, req); if (domain == null) { parent.debug('web', 'Got agent connection with bad domain or blocked IP address ' + req.clientIp + ', holding.'); return; } if (domain.agentkey && ((req.query.key == null) || (domain.agentkey.indexOf(req.query.key) == -1))) { return; } // If agent key is required and not provided or not valid, just hold the websocket and do nothing. + let p = Promise.resolve({cont: true}) + if (domain.keyagents || obj.parent.config.settings.keyagents) { + let keyagentsgrace = domain.keyagentsgrace || obj.parent.config.settings.keyagentsgrace || 0; + if (keyagentsgrace !== 0) { + keyagentsgrace = +(Date.parse(keyagentsgrace)); + } + if (req.query.key == null && (+(new Date()) > keyagentsgrace)) { + return; + } + p = new Promise((resolve, reject)=>{ + if (req.query.key == null && (+(new Date()) < keyagentsgrace)) { + resolve({cont: true}); + return + } + let _hash = obj.crypto.createHash('sha384').update(Buffer.from(req.query.key, "hex")).digest("hex") + db.Get(`agentkey//${_hash}`, (err, data)=>{ + if (err || data.length === 0) { + parent.debug('web', 'Got agent connection with unknown agent key ' + req.clientIp + ', holding.') + resolve({cont: false}) + } + resolve({cont: true, key: data[0]}) + }) + }) + } //console.log('Agent connect: ' + req.clientIp); - try { obj.meshAgentHandler.CreateMeshAgent(obj, obj.db, ws, req, obj.args, domain); } catch (e) { console.log(e); } + p.then((_result)=>{ + if (!_result.cont) { return; } + try { obj.meshAgentHandler.CreateMeshAgent(obj, obj.db, ws, req, obj.args, domain, _result.key); } catch (e) { console.log(e); } + }) + }); // Setup MQTT broker over websocket @@ -6911,7 +6965,34 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF var domain = checkAgentIpAddress(ws, req); if (domain == null) { parent.debug('web', 'Got agent connection with bad domain or blocked IP address ' + req.clientIp + ', holding.'); return; } if (domain.agentkey && ((req.query.key == null) || (domain.agentkey.indexOf(req.query.key) == -1))) { return; } // If agent key is required and not provided or not valid, just hold the websocket and do nothing. - try { obj.meshAgentHandler.CreateMeshAgent(obj, obj.db, ws, req, obj.args, domain); } catch (e) { console.log(e); } + let p = Promise.resolve({cont: true}) + if (domain.keyagents || obj.parent.config.settings.keyagents) { + let keyagentsgrace = domain.keyagentsgrace || obj.parent.config.settings.keyagentsgrace || 0; + if (keyagentsgrace !== 0) { + keyagentsgrace = +(Date.parse(keyagentsgrace)); + } + if (req.query.key == null && (+(new Date()) > keyagentsgrace)) { + return; + } + p = new Promise((resolve, reject)=>{ + if (req.query.key == null && (+(new Date()) < keyagentsgrace)) { + resolve({cont: true}); + return + } + let _hash = obj.crypto.createHash('sha384').update(Buffer.from(req.query.key, "hex")).digest("hex") + db.Get(`agentkey//${_hash}`, (err, data)=>{ + if (err || data.length === 0) { + parent.debug('web', 'Got agent connection with unknown agent key ' + req.clientIp + ', holding.') + resolve({cont: false}) + } + resolve({cont: true, key: data[0]}) + }) + }) + } + p.then((_result)=>{ + if (!_result.cont) { return; } + try { obj.meshAgentHandler.CreateMeshAgent(obj, obj.db, ws, req, obj.args, domain, _result.key); } catch (e) { console.log(e); } + }) }); // Setup mesh relay on alternative agent-only port @@ -9382,6 +9463,17 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF } obj.bad2faTableLastClean = 0; } + obj.generateAgentKey = function (domain) { + let key = obj.crypto.randomBytes(64); + let hash = obj.crypto.createHash('sha384').update(key).digest("hex") + let expirery = (domain.keyagenttimeout || obj.parent.config.settings.keyagenttimeout) + expirery = expirery === undefined ? 60 : expirery + if (expirery != null) { + expirery = +(new Date(Date.now() + (expirery * 60 * 1000) )) + } + let agentKey = { "type": "agentkey", "nodeid": null, "key": hash, "_id": `agentkey//${hash}`, "expire": expirery }; + return [agentKey, key] + } // Hold a websocket until additional arguments are provided within the socket. // This is a generic function that can be used for any websocket to avoid passing arguments in the URL.