diff --git a/ZelBack/config/default.js b/ZelBack/config/default.js index f17c7830a..afe5fdb19 100644 --- a/ZelBack/config/default.js +++ b/ZelBack/config/default.js @@ -9,6 +9,7 @@ module.exports = { server: { allowedPorts: [16127, 16137, 16147, 16157, 16167, 16177, 16187, 16197], apiport: 16127, // homeport is -1, ssl port is +1 + appVerificationAddress: '169.254.42.42', }, database: { url: '127.0.0.1', diff --git a/ZelBack/src/services/appVerificationService.js b/ZelBack/src/services/appVerificationService.js new file mode 100644 index 000000000..560c88560 --- /dev/null +++ b/ZelBack/src/services/appVerificationService.js @@ -0,0 +1,192 @@ +/** + * GetNodeIdentity | NodeBApp -> NodeB (so other end can get public pgp key) + * NodeIdentitySent | NodeBApp -> NodeAApp + * ChallengeRequest | NodeAApp -> NodeA (contains target ip, apiport via identity) + * ChallengeCreated | NodeA (Gets pgp pubkey from ip apiport) + * ChallengeSent | NodeAApp -> NodeBApp + * ChallengeDecryptRequest | NodeBApp -> NodeB + * ChallengeDecrypted | NodeB + * DecryptedSent | NodeBApp -> NodeAApp + * Verified | NodeAApp has now verified NodeBApp + */ + +const config = require('config'); +const log = require('../lib/log'); +const dockerService = require('./dockerService'); +const serviceHelper = require('./serviceHelper'); +const pgpSerivce = require('./pgpService'); +const messageHelper = require('./messageHelper'); +const daemonServiceUtils = require('./daemonService/daemonServiceUtils'); +const generalService = require('./generalService'); + +const { randomBytes } = require('node:crypto'); +const express = require('express'); + +let server = null; + +async function generateChallengeMessage(req, res) { + const parsedBody = serviceHelper.ensureObject(req.body); + const { identity } = parsedBody; + + if (!identity || identity.length !== 64) { + res.statusMessage = 'Authenticating node identity is required (txid)'; + res.status(422).end(); + return; + } + + const app = await dockerService.getAppNameByContainerIp(req.socket.remoteAddress); + if (!app) { + res.statusMessage = 'You are not authorized for this endpoint'; + res.status(403).end(); + return; + } + + const fluxnodeRes = await daemonServiceUtils.executeCall('listfluxnodes', [identity]); + + if (!fluxnodeRes || fluxnodeRes.status !== 'success' || !fluxnodeRes.data.length) { + res.statusMessage = 'Unable to find node identity in deterministicfluxnodelist'; + res.status(422).end(); + return; + } + + // check if more than one?!? + const fluxnode = fluxnodeRes.data[0]; + + // this is ridiculous having to do this all the time. The node identity should always include the port + const [ip, apiport] = fluxnode.ip.includes(':') ? fluxnode.ip.split(':') : [fluxnode.ip, '16127']; + + const message = randomBytes(16).toString('hex'); + const toEncrypt = JSON.stringify({ app, message }); + + // https://1-2-3-4-16127.node.api.runonflux.io/flux/pgp + const hyphenEncodedHostname = `${ip.split('.').join('-')}-${apiport}`; + const pgpEndpoint = `http://${hyphenEncodedHostname}.node.api.runonflux.io/flux/pgp`; + + const { data: pgpPubKeyRes } = await serviceHelper.axiosGet(pgpEndpoint, { timeout: 2000 }); + + if (!pgpPubKeyRes?.status === 'success') { + res.statusMessage = 'Unable to retrieve pgp key for target'; + res.status(422).end(); + } + + const encrypted = await pgpSerivce.encryptMessage(toEncrypt, [pgpPubKeyRes.data]); + + const dataMessage = messageHelper.createDataMessage({ message, encrypted }); + res.json(dataMessage); +} + +async function getNodeIdentity(req, res) { + const app = await dockerService.getAppNameByContainerIp(req.socket.remoteAddress); + if (!app) { + res.statusMessage = 'You are not authorized for this endpoint'; + res.status(403).end(); + return; + } + + let outPoint = null; + try { + // this is reliant on fluxd running + const collateral = await generalService.obtainNodeCollateralInformation(); + outPoint = { txhash: collateral.txhash, outidx: collateral.txindex }; + } catch { + log.error('Error getting collateral info from daemon.'); + } + + if (!outPoint) { + res.statusMessage = 'Unable to get node identity.. try again later'; + res.status(503).end(); + return; + } + + const message = messageHelper.createDataMessage(outPoint); + res.json(message); +} + +async function decryptChallengeMessage(req, res) { + const app = await dockerService.getAppNameByContainerIp(req.socket.remoteAddress); + if (!app) { + res.statusMessage = 'You are not authorized for this endpoint'; + res.status(403).end(); + return; + } + + const parsedBody = serviceHelper.ensureObject(req.body); + const { encrypted } = parsedBody; + + if (!encrypted) { + res.statusMessage = 'Encrypted message not provided'; + res.status(422).end(); + return; + } + + // eslint-disable-next-line no-undef + const { pgpPrivateKey } = userconfig.initial; + + if (!pgpPrivateKey) { + res.statusMessage = 'Pgp key not set'; + res.status(500).end(); + return; + } + + const decrypted = await pgpSerivce.decryptMessage(encrypted, pgpPrivateKey); + + if (!decrypted) { + res.statusMessage = 'Unable to decrypt message'; + res.status(500).end(); + } + + const challenge = JSON.parse(decrypted); + + if (challenge.app !== app) { + res.status(403).end(); + return; + } + + const dataMessage = messageHelper.createDataMessage(challenge.message); + res.json(dataMessage); +} + +function handleError(middleware, req, res, next) { + middleware(req, res, (err) => { + if (err instanceof SyntaxError && err.status === 400 && 'body' in err) { + res.statusMessage = err.message; + return res.sendStatus(400); + } + else if (err) { + log.error(err); + return res.sendStatus(400); + } + + next(); + }); +} + +function start() { + if (server) return; + + const app = express(); + app.use((req, res, next) => { + handleError(express.json(), req, res, next); + }); + app.post('/createchallenge', generateChallengeMessage); + app.post('/decryptchallenge', decryptChallengeMessage); + app.get('/nodeidentity', getNodeIdentity); + app.all('*', (_, res) => res.status(404).end()); + + const bindAddress = config.server.appVerificationAddress; + server = app.listen(80, bindAddress, () => { + log.info(`Server listening on port: 80 address: ${bindAddress}`); + }); +} + +function stop() { + if (server) { + server.close(); + server = null; + } +} + +module.exports = { + start, + stop, +}; diff --git a/ZelBack/src/services/daemonService/daemonServiceUtils.js b/ZelBack/src/services/daemonService/daemonServiceUtils.js index d2180a0fa..4ed7b2c1d 100644 --- a/ZelBack/src/services/daemonService/daemonServiceUtils.js +++ b/ZelBack/src/services/daemonService/daemonServiceUtils.js @@ -37,7 +37,7 @@ let daemonCallRunning = false; * To execute a remote procedure call (RPC). * @param {string} rpc Remote procedure call. * @param {string[]} params RPC parameters. - * @returns {object} Message. + * @returns {Promise} Message. */ async function executeCall(rpc, params) { const rpcparameters = params || []; diff --git a/ZelBack/src/services/dockerService.js b/ZelBack/src/services/dockerService.js index e5b2895be..8fbac8b1b 100644 --- a/ZelBack/src/services/dockerService.js +++ b/ZelBack/src/services/dockerService.js @@ -164,6 +164,53 @@ async function getDockerContainerByIdOrName(idOrName) { const dockerContainer = docker.getContainer(myContainer.Id); return dockerContainer; } + +/** + * + * @returns {Promise} + */ +async function getFluxDockerNetworkSubnets() { + const fluxNetworks = await docker.listNetworks({ + filters: JSON.stringify({ + name: ['fluxDockerNetwork'], + }), + }); + + const subnets = fluxNetworks.map((network) => network.IPAM.Config[0].Subnet); + + return subnets; +} + +async function getAppNameByContainerIp(ip) { + const fluxNetworks = await docker.listNetworks({ + filters: JSON.stringify({ + name: ['fluxDockerNetwork'], + }), + }); + + const fluxNetworkNames = fluxNetworks.map((n) => n.Name); + + const networkPromises = []; + fluxNetworkNames.forEach((networkName) => { + const dockerNetwork = docker.getNetwork(networkName); + networkPromises.push(dockerNetwork.inspect()); + }); + + const fluxNetworkData = await Promise.all(networkPromises); + + let appName = null; + // eslint-disable-next-line no-restricted-syntax + for (const fluxNetwork of fluxNetworkData) { + const subnet = fluxNetwork.IPAM.Config[0].Subnet; + if (serviceHelper.ipInSubnet(ip, subnet)) { + appName = fluxNetwork.Name.split('_')[1]; + break; + } + } + + return appName; +} + /** * Returns low-level information about a container. * @@ -587,6 +634,7 @@ async function appDockerCreate(appSpecifications, appName, isComponent, fullAppS 'max-size': '20m', }, }, + ExtraHosts: [`app.identity.service:${config.server.appVerificationAddress}`], }, }; @@ -972,6 +1020,8 @@ module.exports = { createFluxDockerNetwork, getDockerContainerOnly, getDockerContainerByIdOrName, + getFluxDockerNetworkSubnets, + getAppNameByContainerIp, createFluxAppDockerNetwork, removeFluxAppDockerNetwork, pruneNetworks, diff --git a/ZelBack/src/services/fluxNetworkHelper.js b/ZelBack/src/services/fluxNetworkHelper.js index ff3cd383a..075f820e2 100644 --- a/ZelBack/src/services/fluxNetworkHelper.js +++ b/ZelBack/src/services/fluxNetworkHelper.js @@ -20,6 +20,7 @@ const daemonServiceWalletRpcs = require('./daemonService/daemonServiceWalletRpcs const benchmarkService = require('./benchmarkService'); const verificationHelper = require('./verificationHelper'); const fluxCommunicationUtils = require('./fluxCommunicationUtils'); + const { outgoingConnections, outgoingPeers, incomingPeers, incomingConnections, } = require('./utils/establishedConnections'); @@ -1244,7 +1245,7 @@ async function allowPortApi(req, res) { /** * To check if a firewall is active. - * @returns {boolean} True if a firewall is active. Otherwise false. + * @returns {Promise} True if a firewall is active. Otherwise false. */ async function isFirewallActive() { try { @@ -1262,6 +1263,52 @@ async function isFirewallActive() { } } +/** + * + * @param {string} network + * docker network including mask to allow to verification. For example: 172.23.123.0/24 + * @returns {Promise} + */ +async function allowOnlyDockerNetworksToAppVerification() { + const firewallActive = await isFirewallActive(); + + if (!firewallActive) return; + + const fluxAppDockerNetworks = '172.23.0.0/16'; + const { appVerificationAddress } = config.server; + const allowDockerNetworks = `LANG="en_US.UTF-8" && sudo ufw allow from ${fluxAppDockerNetworks} proto tcp to ${appVerificationAddress}/32 port 80`; + // have to use iptables here as ufw won't filter loopback + const denyRule = `INPUT -i lo ! -s ${fluxAppDockerNetworks} -d ${appVerificationAddress}/32 -j DROP` + const checkDenyRule = `LANG="en_US.UTF-8" && sudo iptables -C ${denyRule}` + const denyAllElse = `LANG="en_US.UTF-8" && sudo iptables -I ${denyRule}`; + + const cmdAsync = util.promisify(nodecmd.get); + + try { + const cmd = await cmdAsync(allowDockerNetworks); + if (serviceHelper.ensureString(cmd).includes('updated') || serviceHelper.ensureString(cmd).includes('existing') || serviceHelper.ensureString(cmd).includes('added')) { + log.info(`Firewall adjusted for network: ${fluxAppDockerNetworks} to address: ${appVerificationAddress}/32`); + } else { + log.warn(`Failed to adjust Firewall for network: ${fluxAppDockerNetworks} to address: ${appVerificationAddress}/32`); + } + } catch (err) { + log.error(err); + } + + const denied = await cmdAsync(checkDenyRule).catch(async (err) => { + if (err.message.includes("Bad rule")) { + try { + await cmdAsync(denyAllElse); + log.info(`Firewall adjusted to deny access to: ${appVerificationAddress}/32`); + } catch (err) { + log.error(err); + } + } + }); + + if (denied) log.info(`Fireall already denying access to ${appVerificationAddress}/32`) +} + /** * To adjust a firewall to allow ports for Flux. */ @@ -1276,6 +1323,7 @@ async function adjustFirewall() { const fluxCommunicationPorts = config.server.allowedPorts; ports = ports.concat(fluxCommunicationPorts); const firewallActive = await isFirewallActive(); + if (firewallActive) { // eslint-disable-next-line no-restricted-syntax for (const port of ports) { @@ -1445,6 +1493,37 @@ async function installNetcat() { } } +/** + * Adds the 169.254 adddress to the loopback interface for use with the fluxapp + * verification service. + */ +async function addAppVerificationIpToLoopback() { + const cmdAsync = util.promisify(nodecmd.get); + + // could also check exists first with: + // ip -f inet addr show lo | grep 169.254.42.42/32 + const ip = config.server.appVerificationAddress; + const addIp = `sudo ip addr add ${ip}/32 dev lo`; + + let ok = false; + try { + await cmdAsync(addIp); + ok = true; + } catch (err) { + if (err.message.includes('File exists')) { + ok = true; + } else { + log.error(err); + } + } + + if (ok) { + log.info(`appVerification IP: ${ip} added to loopback interface`); + } else { + log.warn(`Failed to add appVerification IP ${ip} to loopback interface`); + } +} + module.exports = { minVersionSatisfy, isFluxAvailable, @@ -1472,6 +1551,8 @@ module.exports = { allowPort, allowOutPort, isFirewallActive, + addAppVerificationIpToLoopback, + allowOnlyDockerNetworksToAppVerification, // Exports for testing purposes setStoredFluxBenchAllowed, getStoredFluxBenchAllowed, diff --git a/ZelBack/src/services/pgpService.js b/ZelBack/src/services/pgpService.js index 392439eb4..95673ccb6 100644 --- a/ZelBack/src/services/pgpService.js +++ b/ZelBack/src/services/pgpService.js @@ -101,7 +101,7 @@ async function generateIdentity() { * To encrypt a message with an array of encryption public keys * @param {string} message Message to encrypt * @param {array} encryptionKeys Armored version of array of public key - * @returns {string} Return armored version of encrypted message + * @returns {Promise} Return armored version of encrypted message */ async function encryptMessage(message, encryptionKeys) { try { @@ -124,7 +124,7 @@ async function encryptMessage(message, encryptionKeys) { * To decrypt a message with an armored private key * @param {string} encryptedMessage Message to encrypt * @param {string} decryptionKey Armored version of private key - * @returns {string} Return plain text message + * @returns {Promise} Return plain text message */ async function decryptMessage(encryptedMessage, decryptionKey = userconfig.initial.pgpPrivateKey) { try { diff --git a/ZelBack/src/services/serviceHelper.js b/ZelBack/src/services/serviceHelper.js index 85dc168ae..1fbbecfac 100644 --- a/ZelBack/src/services/serviceHelper.js +++ b/ZelBack/src/services/serviceHelper.js @@ -200,6 +200,24 @@ function commandStringToArray(command) { return splitargs(command); } +/** + * To confirm if ip is in subnet + * @param {string} ip + * @param {string} subnet + * @returns {Boolean} + */ +function ipInSubnet(ip, subnet) { + const [network, mask] = subnet.split('/'); + + // eslint-disable-next-line no-bitwise + const ipAsInt = Number(ip.split('.').reduce((ipInt, octet) => (ipInt << 8) + parseInt(octet || 0, 10), 0)); + // eslint-disable-next-line no-bitwise + const networkAsInt = Number(network.split('.').reduce((ipInt, octet) => (ipInt << 8) + parseInt(octet || 0, 10), 0)); + const maskAsInt = parseInt('1'.repeat(mask) + '0'.repeat(32 - mask), 2); + // eslint-disable-next-line no-bitwise + return (ipAsInt & maskAsInt) === (networkAsInt & maskAsInt); +} + module.exports = { ensureBoolean, ensureNumber, @@ -212,4 +230,5 @@ module.exports = { isDecimalLimit, dockerBufferToString, commandStringToArray, + ipInSubnet, }; diff --git a/ZelBack/src/services/serviceManager.js b/ZelBack/src/services/serviceManager.js index c9c6fefef..7b2c660f5 100644 --- a/ZelBack/src/services/serviceManager.js +++ b/ZelBack/src/services/serviceManager.js @@ -14,6 +14,7 @@ const geolocationService = require('./geolocationService'); const upnpService = require('./upnpService'); const syncthingService = require('./syncthingService'); const pgpService = require('./pgpService'); +const appVerificationService = require('./appVerificationService'); const apiPort = userconfig.initial.apiport || config.server.apiport; const development = userconfig.initial.development || false; @@ -35,6 +36,10 @@ async function startFluxFunctions() { }, 1 * 60 * 60 * 1000); // every 1 hours } + fluxNetworkHelper.addAppVerificationIpToLoopback(); + fluxNetworkHelper.allowOnlyDockerNetworksToAppVerification(); + appVerificationService.start(); + fluxNetworkHelper.installNetcat(); log.info('Initiating MongoDB connection'); await dbHelper.initiateDB(); // either true or throws error