UNPKG

@anlix-io/genieacs-sim

Version:

TR-069 client simulator for GenieACS

533 lines (447 loc) 19.8 kB
"use strict"; const net = require("net"); const EventEmitter = require('events'); const xmlParser = require("./xml-parser"); const xmlUtils = require("./xml-utils"); const methods = require("./methods"); const diagnostics = require("./diagnostics"); const manipulations = require("./manipulations"); const NAMESPACES = { "soap-enc": "http://schemas.xmlsoap.org/soap/encoding/", "soap-env": "http://schemas.xmlsoap.org/soap/envelope/", "xsd": "http://www.w3.org/2001/XMLSchema", "xsi": "http://www.w3.org/2001/XMLSchema-instance", "cwmp": "urn:dslforum-org:cwmp-1-0" }; function createSoapDocument(id, body) { let headerNode = xmlUtils.node( "soap-env:Header", {}, xmlUtils.node("cwmp:ID", { "soap-env:mustUnderstand": 1 }, xmlParser.encodeEntities(id)) ); let bodyNode = xmlUtils.node("soap-env:Body", {}, body); let namespaces = {}; for (let prefix in NAMESPACES) namespaces[`xmlns:${prefix}`] = NAMESPACES[prefix]; let env = xmlUtils.node("soap-env:Envelope", namespaces, [headerNode, bodyNode]); return `<?xml version="1.0" encoding="UTF-8"?>\n${env}`; } function createFaultResponse(code, message) { let fault = xmlUtils.node( "detail", {}, xmlUtils.node("cwmp:Fault", {}, [ xmlUtils.node("FaultCode", {}, code), xmlUtils.node("FaultString", {}, xmlParser.encodeEntities(message)) ]) ); let soapFault = xmlUtils.node("soap-env:Fault", {}, [ xmlUtils.node("faultcode", {}, "Client"), xmlUtils.node("faultstring", {}, "CWMP fault"), fault ]); return soapFault; } class InvalidTaskNameError extends Error { constructor(name, request) { super(`TR-069 Method "${name}" not supported`); this.request = request; // xml parsed by the simulator. } } class Simulator extends EventEmitter { /** * Simulator, by extending EventEmitter, emits the following events: * 'started' - After it's already listening for connection requests from ACS. * No argument in callback. * * 'ready': After it has finished its post boot communication with the ACS. * No argument in callback. * * 'requested'- When it receives a connection request. * No argument in callback. * * 'sent' - At every request sent to the ACS. * Passes an http.ClientRequest instance as argument in callback. * * 'response' - At every a response received from the ACS. * Passes an http.IncomingMessage instance as argument in callback. * * 'error' - When a connection error happens or when a task name is invalid. * Passes an error object as argument in callback. * * 'task' - After ACS receives confirmation that a task has run. * Passes the task structure as argument in callback. * * 'diagnostic' - After ACS has been notified that a diganostic has finished. * Passes the name of the finished diagnostic as argument in callback. * * 'sessionStart' - When a tr069 session is about to start. * Passes the cwmp event string as argument in callback. * * 'sessionEnd' - After both ACS and CPE send empty strings. * Passes the cwmp event string as argument in callback. * */ serialNumber; mac; // Simulator identification values. device; // Data structure to hold tr069 data (tr069 tree). acsUrl; // Simulator will send requests to ACS though this url. verbose; // Flag to control legacy print messages of connections received. periodicInformsDisabled; // Flag to disable automatic informs. // Attributes used by basic communication between ACS and Simulator. requestOptions; http; httpAgent; basicAuth; server; onGoingSession; // Read only flags that indicates a session is currently running. nextInformTimeout; // Timeout object for the next automatic inform. Or undefined. // Internal flag to indicate the ACS sent a requested during an session. pendingAcsRequest; // Read only flag to inform if the Class instance is running. enabled; // Array of CWMP messages to be sent at the start of the next session. pendingMessages = []; // Array of actions that will run, sequentially, after current session // finishes or, if no sessions is running, right away. pendingActions = []; // Diagnostics data. Should not be touched by simulator users./ // State data for available diagnostics. Each key is the name of an available // diagnostic and each value is state data for the corresponding diagnostic. // Each diagnostic holds the following state data: // - running: Timeout object for the timeout that simulates the diagnostic running in background. // - reject: Promise.reject function to interrupt the simulated diagnostic // - result: Function where the consequences of a diagnostic being run is set. diagnosticsStates = {}; diagnosticQueue = []; // Queue for diagnostics waiting to be executed. diagnosticSlots = 1; // Controls the amount diagnostics running in parallel. constructor(device, serialNumber, mac, acsUrl, verbose, periodicInformsDisabled) { super(); this.device = device; this.serialNumber = serialNumber; this.mac = mac; this.acsUrl = acsUrl || 'http://127.0.0.1:57547/'; this.verbose = verbose; this.periodicInformsDisabled = periodicInformsDisabled; // defining which cwmp model version this device is using. if (this.device.get('InternetGatewayDevice.ManagementServer.URL')) this.TR = 'tr098'; else if (this.device.get('Device.ManagementServer.URL')) this.TR = 'tr181'; // Setting initial state of each implemented diagnostic. for (let key in diagnostics) { this.diagnosticsStates[key] = {}; // initializing diagnostic state attributes. this.setResultForDiagnostic(key); // setting default result for diagnostic. } // In order to have a set of methods from a different module be part of the // Simulator Class, we add each of the modules methods to this instance and // make 'this' be the methods scoped 'this'. for (let key in manipulations) { this[key] = manipulations[key].bind(this); } } // Enables of disables periodic informs. // Given 'true' turns on, given 'false' turns off or given 'undefined' toggles. setPeriodicInforms(bool) { if (bool === undefined) { // toggling value. this.periodicInformsDisabled = !this.periodicInformsDisabled; } else if (bool.constructor === Boolean) { // setting value. this.periodicInformsDisabled = !bool; // variable name represents a negation. } if (this.periodicInformsDisabled) { // To disable, clears timeout. clearTimeout(this.nextInformTimeout); this.nextInformTimeout = undefined; } else if (!this.nextInformTimeout) { // To enable, sets next periodic inform. this.setNextPeriodicInform(); } } // Toggles periodic informs. togglePeriodicInforms = () => setPeriodicInforms(); // Stops running simulator instance in background so it can gracefully shut down. async shutDown() { // Stops each running timeout representing a diagnostic being run. for (let key in this.diagnosticsStates) { clearTimeout(this.diagnosticsStates[key].running); } // Stops the timeout for the next periodic inform if (this.nextInformTimeout) { clearTimeout(this.nextInformTimeout); this.nextInformTimeout = undefined; } // Closes the http server. if (this.server) { await new Promise((resolve) => this.server.close(resolve)); this.server = undefined; } this.enabled = false; // flags this simulator as not working (because javascript don't let us delete objects). } // Start procedure for a simulator coming online. Can throw an errors. async start() { if (this.server) { console.log(`Simulator ${this.serialNumber} already started`); return this; } this.enabled = true; // flags this simulator as a working simulator (because javascript don't let us delete objects). let v; // auxiliary variable to store a simulator.device value of a key. if (v = this.device.get("DeviceID.SerialNumber")) v[1] = this.serialNumber; if (v = this.device.get("Device.DeviceInfo.SerialNumber")) v[1] = this.serialNumber; if (v = this.device.get("InternetGatewayDevice.DeviceInfo.SerialNumber")) v[1] = this.serialNumber; if (v = this.device.get("InternetGatewayDevice.LANDevice.1.LANEthernetInterfaceConfig.1.MACAddress")) v[1] = this.mac; else if (v = this.device.get("InternetGatewayDevice.LANDevice.1.LANHostConfigManagement.MACAddress")) v[1] = this.mac; if (v = this.device.get("Device.Ethernet.Interface.1.MACAddress")) v[1] = this.mac; else if (v = this.device.get("Device.Ethernet.Interface.2.MACAddress")) v[1] = this.mac; else if (v = this.device.get("Device.Ethernet.Link.1.MACAddress")) v[1] = this.mac; let username = ""; let password = ""; if (v = this.device.get("Device.ManagementServer.Username")) { username = v[1]; password = this.device.get("Device.ManagementServer.Password")[1]; } else if (v = this.device.get("InternetGatewayDevice.ManagementServer.Username")) { username = v[1]; password = this.device.get("InternetGatewayDevice.ManagementServer.Password")[1]; } this.basicAuth = "Basic " + Buffer.from(`${username}:${password}`).toString("base64"); this.requestOptions = require("url").parse(this.acsUrl); this.http = require(this.requestOptions.protocol.slice(0, -1)); this.httpAgent = new this.http.Agent({keepAlive: true, maxSockets: 1}); let connectionRequestUrl = await this.listenForConnectionRequests(); // Can throw error here. if (v = this.device.get("InternetGatewayDevice.ManagementServer.ConnectionRequestURL")) v[1] = connectionRequestUrl; else if (v = this.device.get("Device.ManagementServer.ConnectionRequestURL")) v[1] = connectionRequestUrl; // device is ready to receive requests. this.emit('started'); await this.startSession(); // device has already informed, to ACS, that it is online and has already // received pending tasks from ACS and is now waiting for something to do. this.emit('ready'); return this; // returning this Simulator instance. } // Instances an http(s) server in order to listen for connection // requests coming from the ACS. async listenForConnectionRequests() { return new Promise((resolve, reject) => { let ip, port; // Start a dummy socket to get the used local ip let socket = net.createConnection({ port: this.requestOptions.port, host: this.requestOptions.hostname, family: 4 }) .on("error", reject) .on("connect", () => { ip = socket.address().address; port = socket.address().port + 1; socket.end(); }) .on("close", () => { const connectionRequestUrl = `http://${ip}:${port}/`; this.server = this.http.createServer((req, res) => { if (this.verbose) console.log(`Simulator ${this.serialNumber} got connection request`); res.end(); this.emit('requested'); if (this.onGoingSession) { this.pendingAcsRequest = true; } else { this.startSession("6 CONNECTION REQUEST"); } }).listen(port, ip, (err) => { if (err) throw err; if (this.verbose) { console.log(`Simulator ${this.serialNumber} listening for connection `+ `requests on ${connectionRequestUrl}`); } resolve(connectionRequestUrl); }); }); }); } // Backbone for the communication between simulator and ACS. // Starts a session with the ACS and, only after it finishes, executes side jobs. async startSession(event="2 PERIODIC") { if (!this.enabled) return; // if simulator has been shutdown, ignore new session. this.onGoingSession = true; // session starts here. clearTimeout(this.nextInformTimeout); // clears timeout in case there is an scheduled session. this.emit('sessionStart', event); this.pendingAcsRequest = false; try { let body = methods.inform(this, event); await this.sendRequest(body); await this.cpeRequest(); } catch (e) { console.log('Simulator internal error.', e); } this.onGoingSession = false; // the soonest point where session has ended. this.emit('sessionEnd', event); // if ACS has sent a request during a session, we start a new session. // if (this.pendingAcsRequest) return this.startSession(); this.setNextPeriodicInform(); // sets timeout for next periodic inform. this.runRequestedDiagnostics(); // executing diagnostic after session has ended. this.runPendingActions(); // starting actions that were waiting current session to finish. } async cpeRequest() { let pendingMessage; while (pendingMessage = this.pendingMessages.shift()) { await pendingMessage((body) => this.sendRequest(body)); } const receivedXml = await this.sendRequest(null); await this.handleMethod(receivedXml); } async sendRequest(content, requestId) { let headers = {}; if (!requestId) requestId = Math.random().toString(36).slice(-8); let xml = content ? createSoapDocument(requestId, content) : undefined; let reqBody = xml || ""; headers["Content-Length"] = reqBody.length; headers["Content-Type"] = "text/xml; charset=\"utf-8\""; headers["Authorization"]= this.basicAuth; if (this.device._cookie) headers["Cookie"] = this.device._cookie; let options = { method: "POST", headers: headers, agent: this.httpAgent }; Object.assign(options, this.requestOptions); return new Promise((resolve) => { // emitting all known errors. let request = this.http.request(options, (response) => { let chunks = []; let bytes = 0; response.on("data", (chunk) => { chunks.push(chunk); return bytes += chunk.length; }).on("end", () => { let offset = 0; let resBody = Buffer.allocUnsafe(bytes); chunks.forEach((chunk) => { chunk.copy(resBody, offset, 0, chunk.length); return offset += chunk.length; }); response.body = resBody.toString(); if (Math.floor(response.statusCode / 100) !== 2) { let {method, href, headers} = options; this.emit('error', new Error(`Unexpected response code ${response.statusCode} with body '${resBody}', `+ `on request '${JSON.stringify({method, href, headers})}' and body '${xmlUtils.formatXML(reqBody)}'.`)); return; } if (+response.headers["Content-Length"] > 0 || resBody.length > 0) xml = xmlParser.parseXml(response.body); else xml = null; if (response.headers["set-cookie"]) this.device._cookie = response.headers["set-cookie"]; this.emit('response', response); resolve(xml); }).on("error", (e) => { this.emit('error', e); }); }); request.on('error', (e) => { this.emit('error', e); }); let requestTimeout = 30000; request.setTimeout(requestTimeout, (err) => { this.emit('error', new Error(`Socket timed out after ${requestTimeout/1000} seconds.`)); }); request.body = reqBody; request.end(reqBody, () => { this.emit('sent', request); }); }); } async handleMethod(xml) { if (!xml) { this.httpAgent.destroy(); return; } let headerElement, bodyElement; let envelope = xml.children[0]; for (const c of envelope.children) { switch (c.localName) { case "Header": headerElement = c; break; case "Body": bodyElement = c; break; } } let requestId; for (let c of headerElement.children) { if (c.localName === "ID") { requestId = xmlParser.decodeEntities(c.text); break; } } let requestElement; for (let c of bodyElement.children) { if (c.name.startsWith("cwmp:")) { requestElement = c; break; } } let method = methods[requestElement.localName]; if (!method) { this.emit('error', new InvalidTaskNameError(requestElement.localName, requestElement)); let body = createFaultResponse(9000, "Method not supported"); let receivedXml = await this.sendRequest(body, requestId); return this.handleMethod(receivedXml); } let body = method(this, requestElement); let receivedXml = await this.sendRequest(body, requestId); // already received, processed, sent values back and got response from ACS. this.emit('task', requestElement); await this.handleMethod(receivedXml); } // sets a timeout to send a periodic inform using configured inform interval. setNextPeriodicInform() { // if there's a timeout already running, we'll stop it before setting another. if (this.nextInformTimeout) clearTimeout(this.nextInformTimeout); // if periodic inform is disabled, we don't put 'startSession()' in a timeout. if (this.periodicInformsDisabled) return; let informInterval = 3; // let v; // if (v = this.device.get("Device.ManagementServer.PeriodicInformInterval")) // informInterval = parseInt(v[1]); // else if (v = this.device.get("InternetGatewayDevice.ManagementServer.PeriodicInformInterval")) // informInterval = parseInt(v[1]); this.nextInformTimeout = setTimeout(this.startSession.bind(this), 1000*informInterval); } // Given a 'result' key and a diagnostic 'name', sets next diagnostic result for the function that // represents that key. In case of error, returns a string containing the error message, else returns nothing. setResultForDiagnostic(name, result='default') { const diagnostic = diagnostics[name]; if (!diagnostic) { return `Simulator ${this.serialNumber} has no diagnostic named '${name}'.`; } const state = this.diagnosticsStates[name]; if (state.running && state.running._destroyed === false) { console.log(`Simulator ${this.serialNumber} warning: '${name}' diagnostic already running. `+ `You might want to set the expected result before initiating the diagnostic.`); } const nextResultFunc = diagnostic.results[result]; if (!nextResultFunc) { return `Simulator ${this.serialNumber} has no '${result}' result `+ `that can be expected for a '${name}' diagnostic.` } state.result = nextResultFunc; } // Runs queued diagnostic if possible. async runRequestedDiagnostics() { // if diagnostic execution is fully occupied we don't start anything // because there is a logic flux already inside the loop, running all // diagnostics sequentially. if (!this.diagnosticSlots) return; let diagnostic; while (diagnostic = this.diagnosticQueue.shift()) { this.diagnosticSlots--; // consuming a diagnostic execution slot. await diagnostic() .catch(() => {}); // interrupted diagnostics are ignored. this.diagnosticSlots++; // returning a diagnostic execution slot. } } // Runs queued promise function if no session is running. async runPendingActions(actionFunc) { if (actionFunc) this.pendingActions.push(actionFunc); // if there is an on going session we don't run any pending events and wait // the current session to call this function again. if (this.onGoingSession) return; let pendingAction; while (pendingAction = this.pendingActions.shift()) { await pendingAction(); } } } exports.Simulator = Simulator; exports.InvalidTaskNameError = InvalidTaskNameError;