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
text/typescript
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 <s:Body> 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
}
}