| 1 | /** |
| 2 | * @description MeshCentral Firebase communication module |
| 3 | * @author Ylian Saint-Hilaire |
| 4 | * @license Apache-2.0 |
| 5 | * @version v0.0.1 |
| 6 | */ |
| 7 | |
| 8 | /*xjslint node: true */ |
| 9 | /*xjslint plusplus: true */ |
| 10 | /*xjslint maxlen: 256 */ |
| 11 | /*jshint node: true */ |
| 12 | /*jshint strict: false */ |
| 13 | /*jshint esversion: 6 */ |
| 14 | "use strict"; |
| 15 | |
| 16 | // Initialize the Firebase Admin SDK |
| 17 | module.exports.CreateFirebase = function (parent, serviceAccount) { |
| 18 | |
| 19 | // Import the Firebase Admin SDK |
| 20 | const admin = require('firebase-admin'); |
| 21 | |
| 22 | const obj = {}; |
| 23 | obj.messageId = 0; |
| 24 | obj.relays = {}; |
| 25 | obj.stats = { |
| 26 | mode: 'Real', |
| 27 | sent: 0, |
| 28 | sendError: 0, |
| 29 | received: 0, |
| 30 | receivedNoRoute: 0, |
| 31 | receivedBadArgs: 0 |
| 32 | }; |
| 33 | |
| 34 | const tokenToNodeMap = {}; // Token --> { nid: nodeid, mid: meshid } |
| 35 | |
| 36 | // Initialize Firebase Admin with server key and project ID |
| 37 | if (!admin.apps.length) { |
| 38 | admin.initializeApp({ credential: admin.credential.cert(serviceAccount) }); |
| 39 | } |
| 40 | |
| 41 | // Setup logging |
| 42 | if (parent.config.firebase && (parent.config.firebase.log === true)) { |
| 43 | obj.logpath = parent.path.join(parent.datapath, 'firebase.txt'); |
| 44 | obj.log = function (msg) { try { parent.fs.appendFileSync(obj.logpath, new Date().toLocaleString() + ': ' + msg + '\r\n'); } catch (ex) { console.log('ERROR: Unable to write to firebase.txt.'); } } |
| 45 | } else { |
| 46 | obj.log = function () { } |
| 47 | } |
| 48 | |
| 49 | // Function to send notifications |
| 50 | obj.sendToDevice = function (node, payload, options, func) { |
| 51 | if (typeof node === 'string') { |
| 52 | parent.db.Get(node, function (err, docs) { |
| 53 | if (!err && docs && docs.length === 1) { |
| 54 | obj.sendToDeviceEx(docs[0], payload, options, func); |
| 55 | } else { |
| 56 | func(0, 'error'); |
| 57 | } |
| 58 | }); |
| 59 | } else { |
| 60 | obj.sendToDeviceEx(node, payload, options, func); |
| 61 | } |
| 62 | }; |
| 63 | |
| 64 | // Send an outbound push notification |
| 65 | obj.sendToDeviceEx = function (node, payload, options, func) { |
| 66 | if (!node || typeof node.pmt !== 'string') { |
| 67 | func(0, 'error'); |
| 68 | return; |
| 69 | } |
| 70 | |
| 71 | obj.log('sendToDevice, node:' + node._id + ', payload: ' + JSON.stringify(payload) + ', options: ' + JSON.stringify(options)); |
| 72 | |
| 73 | // Fill in our lookup table |
| 74 | if (node._id) { |
| 75 | tokenToNodeMap[node.pmt] = { |
| 76 | nid: node._id, |
| 77 | mid: node.meshid, |
| 78 | did: node.domain |
| 79 | }; |
| 80 | } |
| 81 | |
| 82 | const message = { |
| 83 | token: node.pmt, |
| 84 | notification: payload.notification, |
| 85 | data: payload.data, |
| 86 | android: { |
| 87 | priority: options.priority || 'high', |
| 88 | ttl: options.timeToLive ? options.timeToLive * 1000 : undefined |
| 89 | } |
| 90 | }; |
| 91 | |
| 92 | admin.messaging().send(message).then(function (response) { |
| 93 | obj.stats.sent++; |
| 94 | obj.log('Success'); |
| 95 | func(response); |
| 96 | }).catch(function (error) { |
| 97 | obj.stats.sendError++; |
| 98 | obj.log('Fail: ' + error); |
| 99 | func(0, error); |
| 100 | }); |
| 101 | }; |
| 102 | |
| 103 | // Setup a two way relay |
| 104 | obj.setupRelay = function (ws) { |
| 105 | ws.relayId = getRandomPassword(); |
| 106 | while (obj.relays[ws.relayId]) { ws.relayId = getRandomPassword(); } |
| 107 | obj.relays[ws.relayId] = ws; |
| 108 | |
| 109 | ws.on('message', function (msg) { |
| 110 | parent.debug('email', 'FBWS-Data(' + this.relayId + '): ' + msg); |
| 111 | if (typeof msg === 'string') { |
| 112 | obj.log('Relay: ' + msg); |
| 113 | |
| 114 | let data; |
| 115 | try { data = JSON.parse(msg); } catch (ex) { return; } |
| 116 | if (typeof data !== 'object') return; |
| 117 | if (!parent.common.validateObjectForMongo(data, 4096)) return; |
| 118 | if (typeof data.pmt !== 'string' || typeof data.payload !== 'object') return; |
| 119 | |
| 120 | data.payload.data = data.payload.data || {}; |
| 121 | data.payload.data.r = ws.relayId; |
| 122 | |
| 123 | obj.sendToDevice({ pmt: data.pmt }, data.payload, data.options, function (id, err) { |
| 124 | if (!err) { |
| 125 | try { ws.send(JSON.stringify({ sent: true })); } catch (ex) { } |
| 126 | } else { |
| 127 | try { ws.send(JSON.stringify({ sent: false })); } catch (ex) { } |
| 128 | } |
| 129 | }); |
| 130 | } |
| 131 | }); |
| 132 | |
| 133 | // If error, close the relay |
| 134 | ws.on('error', function (err) { |
| 135 | parent.debug('email', 'FBWS-Error(' + this.relayId + '): ' + err); |
| 136 | delete obj.relays[this.relayId]; |
| 137 | }); |
| 138 | |
| 139 | // Close the relay |
| 140 | ws.on('close', function () { |
| 141 | parent.debug('email', 'FBWS-Close(' + this.relayId + ')'); |
| 142 | delete obj.relays[this.relayId]; |
| 143 | }); |
| 144 | }; |
| 145 | |
| 146 | function getRandomPassword() { |
| 147 | return Buffer.from(parent.crypto.randomBytes(9), 'binary').toString('base64').replace(/\//g, '@'); |
| 148 | } |
| 149 | |
| 150 | return obj; |
| 151 | }; |
| 152 | |
| 153 | |
| 154 | // Construct the Firebase object |
| 155 | module.exports.CreateFirebaseRelay = function (parent, url, key) { |
| 156 | var obj = {}; |
| 157 | obj.messageId = 0; |
| 158 | obj.stats = { |
| 159 | mode: "Relay", |
| 160 | sent: 0, |
| 161 | sendError: 0, |
| 162 | received: 0, |
| 163 | receivedNoRoute: 0, |
| 164 | receivedBadArgs: 0 |
| 165 | } |
| 166 | const WebSocket = require('ws'); |
| 167 | const https = require('https'); |
| 168 | const querystring = require('querystring'); |
| 169 | const relayUrl = new URL(url); |
| 170 | parent.debug('email', 'CreateFirebaseRelay-Setup'); |
| 171 | |
| 172 | // Setup logging |
| 173 | if (parent.config.firebaserelay && (parent.config.firebaserelay.log === true)) { |
| 174 | obj.logpath = parent.path.join(parent.datapath, 'firebaserelay.txt'); |
| 175 | obj.log = function (msg) { try { parent.fs.appendFileSync(obj.logpath, new Date().toLocaleString() + ': ' + msg + '\r\n'); } catch (ex) { console.log('ERROR: Unable to write to firebaserelay.txt.'); } } |
| 176 | } else { |
| 177 | obj.log = function () { } |
| 178 | } |
| 179 | |
| 180 | obj.log('Starting relay to: ' + relayUrl.href); |
| 181 | if (relayUrl.protocol == 'wss:') { |
| 182 | // Setup two-way push notification channel |
| 183 | obj.wsopen = false; |
| 184 | obj.tokenToNodeMap = {}; // Token --> { nid: nodeid, mid: meshid } |
| 185 | obj.backoffTimer = 0; |
| 186 | obj.connectWebSocket = function () { |
| 187 | if (obj.reconnectTimer != null) { try { clearTimeout(obj.reconnectTimer); } catch (ex) { } delete obj.reconnectTimer; } |
| 188 | if (obj.wsclient != null) return; |
| 189 | obj.wsclient = new WebSocket(relayUrl.href + (key ? ('?key=' + key) : ''), { rejectUnauthorized: false }) |
| 190 | obj.wsclient.on('open', function () { |
| 191 | obj.lastConnect = Date.now(); |
| 192 | parent.debug('email', 'FBWS-Connected'); |
| 193 | obj.wsopen = true; |
| 194 | }); |
| 195 | obj.wsclient.on('message', function (msg) { |
| 196 | parent.debug('email', 'FBWS-Data(' + msg.length + '): ' + msg); |
| 197 | obj.log('Received(' + msg.length + '): ' + msg); |
| 198 | var data = null; |
| 199 | try { data = JSON.parse(msg) } catch (ex) { } |
| 200 | if (typeof data != 'object') return; |
| 201 | if (typeof data.from != 'string') return; |
| 202 | if (typeof data.data != 'object') return; |
| 203 | if (typeof data.category != 'string') return; |
| 204 | processMessage(data.messageId, data.from, data.data, data.category); |
| 205 | }); |
| 206 | obj.wsclient.on('error', function (err) { obj.log('Error: ' + err); }); |
| 207 | obj.wsclient.on('close', function (a, b, c) { |
| 208 | parent.debug('email', 'FBWS-Disconnected'); |
| 209 | obj.wsclient = null; |
| 210 | obj.wsopen = false; |
| 211 | |
| 212 | // Compute the backoff timer |
| 213 | if (obj.reconnectTimer == null) { |
| 214 | if ((obj.lastConnect != null) && ((Date.now() - obj.lastConnect) > 10000)) { obj.backoffTimer = 0; } |
| 215 | obj.backoffTimer += 1000; |
| 216 | obj.backoffTimer = obj.backoffTimer * 2; |
| 217 | if (obj.backoffTimer > 1200000) { obj.backoffTimer = 600000; } // Maximum 10 minutes backoff. |
| 218 | obj.reconnectTimer = setTimeout(obj.connectWebSocket, obj.backoffTimer); |
| 219 | } |
| 220 | }); |
| 221 | } |
| 222 | |
| 223 | function processMessage(messageId, from, data, category) { |
| 224 | // Lookup node information from the cache |
| 225 | var ninfo = obj.tokenToNodeMap[from]; |
| 226 | if (ninfo == null) { obj.stats.receivedNoRoute++; return; } |
| 227 | |
| 228 | if ((data != null) && (data.con != null) && (data.s != null)) { // Console command |
| 229 | obj.stats.received++; |
| 230 | parent.webserver.routeAgentCommand({ action: 'msg', type: 'console', value: data.con, sessionid: data.s }, ninfo.did, ninfo.nid, ninfo.mid); |
| 231 | } else { |
| 232 | obj.stats.receivedBadArgs++; |
| 233 | } |
| 234 | } |
| 235 | |
| 236 | obj.sendToDevice = function (node, payload, options, func) { |
| 237 | if (typeof node == 'string') { |
| 238 | parent.db.Get(node, function (err, docs) { if ((err == null) && (docs != null) && (docs.length == 1)) { obj.sendToDeviceEx(docs[0], payload, options, func); } else { func(0, 'error'); } }) |
| 239 | } else { |
| 240 | obj.sendToDeviceEx(node, payload, options, func); |
| 241 | } |
| 242 | } |
| 243 | |
| 244 | obj.sendToDeviceEx = function (node, payload, options, func) { |
| 245 | parent.debug('email', 'Firebase-sendToDevice-webSocket'); |
| 246 | if ((node == null) || (typeof node.pmt != 'string')) { func(0, 'error'); return; } |
| 247 | obj.log('sendToDevice, node:' + node._id + ', payload: ' + JSON.stringify(payload) + ', options: ' + JSON.stringify(options)); |
| 248 | |
| 249 | // Fill in our lookup table |
| 250 | if (node._id != null) { obj.tokenToNodeMap[node.pmt] = { nid: node._id, mid: node.meshid, did: node.domain } } |
| 251 | |
| 252 | // Fill in the server agent cert hash |
| 253 | if (payload.data == null) { payload.data = {}; } |
| 254 | if (payload.data.shash == null) { payload.data.shash = parent.webserver.agentCertificateHashBase64; } // Add the server agent hash, new Android agents will reject notifications that don't have this. |
| 255 | |
| 256 | // If the web socket is open, send now |
| 257 | if (obj.wsopen == true) { |
| 258 | try { obj.wsclient.send(JSON.stringify({ pmt: node.pmt, payload: payload, options: options })); } catch (ex) { func(0, 'error'); obj.stats.sendError++; return; } |
| 259 | obj.stats.sent++; |
| 260 | obj.log('Sent'); |
| 261 | func(1); |
| 262 | } else { |
| 263 | // TODO: Buffer the push messages until TTL. |
| 264 | obj.stats.sendError++; |
| 265 | obj.log('Error'); |
| 266 | func(0, 'error'); |
| 267 | } |
| 268 | } |
| 269 | obj.connectWebSocket(); |
| 270 | } else if (relayUrl.protocol == 'https:') { |
| 271 | // Send an outbound push notification using an HTTPS POST |
| 272 | obj.pushOnly = true; |
| 273 | |
| 274 | obj.sendToDevice = function (node, payload, options, func) { |
| 275 | if (typeof node == 'string') { |
| 276 | parent.db.Get(node, function (err, docs) { if ((err == null) && (docs != null) && (docs.length == 1)) { obj.sendToDeviceEx(docs[0], payload, options, func); } else { func(0, 'error'); } }) |
| 277 | } else { |
| 278 | obj.sendToDeviceEx(node, payload, options, func); |
| 279 | } |
| 280 | } |
| 281 | |
| 282 | obj.sendToDeviceEx = function (node, payload, options, func) { |
| 283 | parent.debug('email', 'Firebase-sendToDevice-httpPost'); |
| 284 | if ((node == null) || (typeof node.pmt != 'string')) return; |
| 285 | |
| 286 | // Fill in the server agent cert hash |
| 287 | if (payload.data == null) { payload.data = {}; } |
| 288 | if (payload.data.shash == null) { payload.data.shash = parent.webserver.agentCertificateHashBase64; } // Add the server agent hash, new Android agents will reject notifications that don't have this. |
| 289 | |
| 290 | obj.log('sendToDevice, node:' + node._id + ', payload: ' + JSON.stringify(payload) + ', options: ' + JSON.stringify(options)); |
| 291 | const querydata = querystring.stringify({ 'msg': JSON.stringify({ pmt: node.pmt, payload: payload, options: options }) }); |
| 292 | |
| 293 | // Send the message to the relay |
| 294 | const httpOptions = { |
| 295 | hostname: relayUrl.hostname, |
| 296 | port: relayUrl.port ? relayUrl.port : 443, |
| 297 | path: relayUrl.pathname + (key ? ('?key=' + key) : ''), |
| 298 | method: 'POST', |
| 299 | //rejectUnauthorized: false, // DEBUG |
| 300 | headers: { |
| 301 | 'Content-Type': 'application/x-www-form-urlencoded', |
| 302 | 'Content-Length': querydata.length |
| 303 | } |
| 304 | } |
| 305 | const req = https.request(httpOptions, function (res) { |
| 306 | obj.log('Response: ' + res.statusCode); |
| 307 | if (res.statusCode == 200) { obj.stats.sent++; } else { obj.stats.sendError++; } |
| 308 | if (func != null) { func(++obj.messageId, (res.statusCode == 200) ? null : 'error'); } |
| 309 | }); |
| 310 | parent.debug('email', 'Firebase-sending'); |
| 311 | req.on('error', function (error) { obj.stats.sent++; func(++obj.messageId, 'error'); }); |
| 312 | req.write(querydata); |
| 313 | req.end(); |
| 314 | } |
| 315 | } |
| 316 | |
| 317 | return obj; |
| 318 | }; |