UNPKG

onvif-nvt-ts

Version:

Wrapper for ONVIF spec to control NVT (Network Video Transitter) devices. Forked and added TypeScript support from onvif-nvt.

439 lines (397 loc) 13.3 kB
import Crypto from 'crypto' import fs from 'fs' import path from 'path' import Request from 'request' import Xml2js from 'xml2js' import * as Config from './config' import * as Save from './save-xml' /** * SOAP management (sending, receiving, parsing) class for ONVIF modules. */ export default class Soap { HTTP_TIMEOUT = 3000 private username: string = null private password: string = null /** * Internal method for parsing SOAP responses. * @param soap The XML to parse. */ private parse(soap: string) { return new Promise<{ raw?: boolean; soap?: string; parsed?: any }>((resolve, reject) => { const prefix = soap.substring(0, 2) if (prefix === '--') { // this is a multi-part xml with attachment // it is up to the receiver to parse. This is // usually from GetSystemLog and the binary // part could be anything. Reference your // camera specs for dealing with it. resolve({ raw: true, soap }) } else { const opts = { explicitRoot: false, explicitArray: false, // 'ignoreAttrs' : true, ignoreAttrs: false, tagNameProcessors: [ function (name: string) { // strip namespaces /* eslint-disable no-useless-escape */ const m = name.match(/^([^\:]+)\:([^\:]+)$/) /* eslint-enable no-useless-escape */ return m ? m[2] : name } ] } // console.log(soap) Xml2js.parseString(soap, opts, (error, results) => { if (error) { error['soap'] = soap reject(error) } else { resolve({ parsed: results, soap }) } }) } }) } /** * Internal method used by the module classes. * @param params Object containing required parameters to create a SOAP request. * @param params.body Description in the &lt;s:Body&gt; of the generated xml. * @param params.xmlns A list of xmlns attributes used in the body * e.g., xmlns:tds="http://www.onvif.org/ver10/device/wsdl". * @param params.diff Time difference [ms]. * @param params.username The user name. * @param params.password The user Password. * @param params.subscriptionId */ createRequest(params: { body: string xmlns: string[] diff: number username?: string password?: string subscriptionId?: any }) { let soap = '' soap += '<?xml version="1.0" encoding="UTF-8"?>' soap += '<s:Envelope' soap += ' xmlns:s="http://www.w3.org/2003/05/soap-envelope"' soap += ' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"' soap += ' xmlns:xsd="http://www.w3.org/2001/XMLSchema"' soap += ' xmlns:wsa5="http://www.w3.org/2005/08/addressing"' soap += ' xmlns:wsse="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd"' soap += ' xmlns:wsu="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd"' soap += ' xmlns:tt="http://www.onvif.org/ver10/schema"' soap += ' xmlns:ter="http://www.onvif.org/ver10/error"' if (params.xmlns && Array.isArray(params.xmlns)) { params.xmlns.forEach(ns => { // make sure we don't add it twice (hikvision will fail) const index = soap.indexOf(ns) if (index < 0) { soap += ' ' + ns } }) } soap += '>' soap += '<s:Header>' if (params.subscriptionId) { const address = this.getAddress(params.subscriptionId) if (address) { soap += '<wsa5:To s:mustUnderstand="true">' soap += address soap += '</wsa5:To>' } } // soap += '<wsa5:Action s:mustUnderstand="1">' // soap += 'http://www.onvif.org/ver10/events/wsdl/EventPortType/CreatePullPointSubscriptionRequest' // soap += '</wsa5:Action>' // soap += '<wsa5:MessageID>' // soap += 'urn:uuid:cca999f8-b0e1-4e4e-ac7e-04a074d49fbf' // soap += '</wsa5:MessageID>' soap += '<wsa5:ReplyTo>' soap += '<wsa5:Address>http://www.w3.org/2005/08/addressing/anonymous</wsa5:Address>' soap += '</wsa5:ReplyTo>' if (params.username) { this.username = params.username this.password = params.password || '' soap += this.createUserToken(params.diff, params.username, params.password) } soap += '</s:Header>' soap += '<s:Body>' + params.body + '</s:Body>' soap += '</s:Envelope>' return soap.replace(/\>\s+\</g, '><') } /** * Send a SOAP request to the specified serviceAddress. * @param service The service name. * @param serviceAddress The service address. * @param methodName The request name. * @param soapEnvelope The request SOAP envelope. */ async makeRequest(service: string, serviceAddress: { href: string }, methodName: string, soapEnvelope: string) { Save.saveXml(service, methodName + '.Request', soapEnvelope) const xml = await this.runRequest(service, serviceAddress, methodName, soapEnvelope) const response = await this.parse(xml) if (response.raw) { Save.saveXml(service, methodName + '.Response', response.soap) return response } const fault = this.getFault(response.parsed) if (fault) { Save.saveXml(service, methodName + '.Error', xml) const err = new Error(`${methodName}`) err['fault'] = fault err['soap'] = xml throw err } else { const parsed = this.parseResponse(methodName, response.parsed) if (parsed) { const res = { soap: xml, schemas: parsed.$ || '', data: parsed } Save.saveXml(service, methodName + '.Response', xml) return res } else { throw new Error(methodName + ':The device seems to not support the ' + methodName + '() method.') } } } /** * Internal method to send a SOAP request. * @param service The service. * @param serviceAddress The service address. * @param methodName The request name. * @param soapEnvelope The request SOAP envelope. */ private runRequest(service: string, serviceAddress: { href: string }, methodName: string, soapEnvelope: string) { return new Promise<string>((resolve, reject) => { if (Config.isTest()) { // in testing mode (for Jest) const testCameraType = Config.getCameraType() const testService = service let filePath = path.resolve( __dirname, `../../test/data/xml/${testCameraType}/${testService}/${methodName}.Response.xml` ) // see if the file exists if (!fs.existsSync(filePath)) { // see if there is an Error file filePath = path.resolve( __dirname, `../../test/data/xml/${testCameraType}/${testService}/${methodName}.Error.xml` ) } if (!fs.existsSync(filePath)) { throw new Error(`File does not exist for test: ${filePath}`) } // it's good, read it in const xml = fs.readFileSync(filePath, 'utf8') resolve(xml) } else { // some cameras enable HTTP digest or digest realm, // so using 'Request' to handle this for us. const options = { method: 'POST', uri: serviceAddress.href, // gzip: true, encoding: 'utf8', headers: { 'Content-Type': 'application/soap+xml; charset=utf-8;', 'Content-Length': Buffer.byteLength(soapEnvelope) }, body: soapEnvelope, auth: { user: this.username, pass: this.password, sendImmediately: false } } Request(options, (error, response, body) => { if (error) { console.error(error) reject(error) } else { if (response.statusCode === 200) { resolve(body) } else { reject(response) } } }) } }) } private parseResponse(methodName: string, response: { Body?: any }) { const s0 = response.Body if (!s0) { return null } const responseName = methodName + 'Response' if (responseName in s0) { return s0 } else { return null } } /** * Parses results to see if there is a fault. * @param results The results of a communication with a server. */ getFault(results: { Body: { Fault?: any } }) { if (results.Body?.Fault) { const bodyFault = results.Body.Fault const r1 = this.parseForReason(bodyFault) const c1 = this.parseForCode(bodyFault) const d1 = this.parseForDetail(bodyFault) return { reason: r1, code: c1, detail: d1 } } } parseForCode(fault: any) { let code = '' if ('Code' in fault) { const faultCode = fault.Code if ('Value' in faultCode) { const faultValue = faultCode.Value if ('Subcode' in faultCode) { const faultSubcode = faultCode.Subcode if ('Value' in faultSubcode) { const faultSubcodeValue = faultSubcode.Value code = faultValue + '|' + faultSubcodeValue } else { code = faultSubcode } } else { code = faultValue } } } return code } parseForDetail(fault: any) { let detail = '' if ('Detail' in fault) { const faultDetail = fault.Detail if ('Text' in faultDetail) { const faultText = faultDetail.Text if (typeof faultText === 'string') { detail = faultText } else if (typeof faultText === 'object' && '_' in faultText) { detail = faultText._ } } } return detail } parseForReason(fault: any) { let reason = '' if ('Reason' in fault) { const faultReason = fault.Reason if ('Text' in faultReason) { const faultText = faultReason.Text if (typeof faultText === 'string') { reason = faultText } else if (typeof faultText === 'object' && '_' in faultText) { reason = faultText._ } } } else if ('faultstring' in fault) { reason = fault.faultstring } return reason } /** * Internal method used to create the user token xml. * @param diff The server timeDiff [ms]. * @param user The user name. * @param pass The user password. */ createUserToken(diff: number, user: string, pass?: string) { if (!diff) { diff = 0 } if (!pass) { pass = '' } const created = new Date(Date.now() + diff).toISOString() // 10 second expiry const expires = new Date(Date.now() + diff + 10000).toISOString() const nonceBuffer = this.createNonce(16) const nonceBase64 = nonceBuffer.toString('base64') const shasum = Crypto.createHash('sha1') shasum.update(Buffer.concat([nonceBuffer, Buffer.from(created), Buffer.from(pass)])) const digest = shasum.digest('base64') let soap = '' soap += '<wsse:Security s:mustUnderstand="1">' soap += ' <wsu:Timestamp wsu:Id="Time">' soap += ' <wsu:Created>' + created + '</wsu:Created>' soap += ' <wsu:Expires>' + expires + '</wsu:Expires>' soap += ' </wsu:Timestamp>' soap += ' <wsse:UsernameToken wsu:Id="User">' soap += ' <wsse:Username>' + user + '</wsse:Username>' soap += ' <wsse:Password Type="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordDigest">' + digest + '</wsse:Password>' soap += ' <wsse:Nonce EncodingType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary">' + nonceBase64 + '</wsse:Nonce>' soap += ' <wsu:Created xmlns="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd">' + created + '</wsu:Created>' soap += ' </wsse:UsernameToken>' soap += '</wsse:Security>' return soap } createNonce(digit: number) { const nonce = Buffer.alloc(digit) for (let i = 0; i < digit; i++) { nonce.writeUInt8(Math.floor(Math.random() * 256), i) } return nonce } getAddress(subscriptionid: { Address?: string }) { if (subscriptionid) { if (subscriptionid.Address) { return subscriptionid.Address } } return null } getCustomSubscriptionIdXml(subscriptionId: { _?: string; $?: any }) { if (subscriptionId) { if (subscriptionId._) { const id = subscriptionId._ let xml: string | null = null if (subscriptionId.$) { const keys = Object.keys(subscriptionId.$) const tag = keys[0] const url = subscriptionId.$[tag] if (id && tag && url) { const tags = tag.split(':') // don't need Action tag xml = '<SubscriptionId s:mustUnderstand="1" s:IsReferenceParameter="1" ' + tags[0] + '="' + url + '">' + id + '</SubscriptionId>' // console.log(xml) } } return xml } } return null } }