/*
* Created on Tue Aug 16 2022
*
* Copyright (c) 2022 Smart DCC Limited
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
import type { NodeDef, NodeAPI, NodeMessage } from 'node-red'
import type {
ConfigNode,
DspEndpoint,
MessageStore,
Properties,
WSMessageDTO,
} from './dccboxed-config.properties'
import * as bodyParser from 'body-parser'
import { signDuis, validateDuis } from '@smartdcc/duis-sign-wrap'
import {
constructDuis,
isSimplifiedDuisOutputResponse,
isSimplifiedDuisResponseBody_ResponseMessage_X,
parseDuis,
SimplifiedDuisInput,
SimplifiedDuisOutputResponse,
} from '@smartdcc/duis-parser'
import { EventEmitter } from 'node:events'
import { BoxedKeyStore } from '@smartdcc/dccboxed-keystore'
import got from 'got'
import { parse as contentType } from 'content-type'
import { inspect } from 'node:util'
import { signGroupingHeader } from '@smartdcc/gbcs-parser'
import { ServerKeyStore } from './gbcs-node.common'
import { open } from 'node:fs/promises'
const endpoints: Record = {
'Non-Device Service': '/api/v1/serviceD',
'Send Command Service': '/api/v1/serviceS',
'Transform Service': '/api/v1/serviceT',
}
export = function (RED: NodeAPI) {
const usedEndpoints: string[] = []
function ConfigConstruct(this: ConfigNode, config: Properties & NodeDef) {
RED.nodes.createNode(this, config)
this.config = config
this.events = new EventEmitter()
if (usedEndpoints.indexOf(config.responseEndpoint) !== -1) {
this.error('duis response endpoint is not unique')
}
usedEndpoints.push(config.responseEndpoint)
BoxedKeyStore.new(
config.host,
(config.localKeyStore?.length ?? 0) > 1
? config.localKeyStore
: undefined,
)
.then((ks) => {
this.keyStore = ks
})
.catch((e) => {
RED.log.error(`failed to load dcc boxed key store: ${e}`)
})
const messageStore: MessageStore & {
dict: Record
} = {
dict: {},
store(reqid, msg) {
if (!reqid) {
return
}
const id = `${reqid.originatorId}:${reqid.targetId}:${reqid.counter}`
/* shallow copy, consider if deep is appropriate */
this.dict[id] = Object.assign({}, msg)
},
retrieve(reqid) {
const id = `${reqid?.originatorId}:${reqid?.targetId}:${reqid?.counter}`
if (reqid && id in this.dict) {
const msg = this.dict[id]
delete this.dict[id]
return msg
}
return undefined
},
}
this.messageStore = messageStore
const asyncWorkerSend = async (
status: (status: string) => void | Promise,
req: SimplifiedDuisInput,
endpoint: DspEndpoint,
preserveCounter: boolean,
): Promise => {
await status(`${endpoint}: signing duis`)
const preSignedXml = constructDuis('simplified', req)
const signedXml = await signDuis({ xml: preSignedXml, preserveCounter })
this.logger(signedXml)
await status(`${endpoint}: requesting`)
const response = await got(
`http://${this.config.host}:${this.config.port}${endpoints[endpoint]}`,
{
timeout: { request: 3000 },
headers: { 'Content-Type': 'application/xml' },
method: 'POST',
body: signedXml,
throwHttpErrors: true,
followRedirect: true,
},
)
if (
typeof response.headers['content-type'] !== 'string' ||
contentType(response.headers['content-type']).type !== 'application/xml'
) {
throw new Error(
`incorrect content-type header received, expected application/xml, received: ${response.headers['content-type']}`,
)
}
await status(`${endpoint}: validating`)
this.logger(response.body)
const validatedDuis = await validateDuis({ xml: response.body })
const res = parseDuis('simplified', validatedDuis)
if (!isSimplifiedDuisOutputResponse(res)) {
RED.log.error(inspect(response, { depth: 10, colors: true }))
throw new Error('invalid simplified duis response')
}
this.status({
fill: res.header.responseCode.startsWith('I') ? 'green' : 'red',
shape: 'dot',
text: `${endpoint}: result code: ${res.header.responseCode}`,
})
return res
}
this.logger = () => {
/* no op */
}
switch (config.loggerType) {
case 'stdout':
this.logger = (s) => RED.log.info(s)
break
case 'file':
;(async () => {
if (typeof config.logger === 'string') {
this.logfile = await open(config.logger, 'a', 0o660)
this.logger = async (s) => {
await this.logfile?.write(`--- ${new Date().toString()} ---\n`)
await this.logfile?.write(s)
await this.logfile?.write('\n')
}
} else {
throw new Error('duis logging disabled as no logger file provided')
}
})().catch((e) => {
RED.log.warn(`unable to start duis logger: ${e}`)
})
break
}
this.request = async (status, endpoint, req) => {
const res = await asyncWorkerSend(
status,
req,
endpoint,
req.header.requestId.counter !== 0 &&
req.header.requestId.counter !== BigInt(0),
)
if (
endpoint === 'Transform Service' &&
res.header.responseCode === 'I0'
) {
if (
res.header.requestId &&
isSimplifiedDuisResponseBody_ResponseMessage_X('PreCommand', res.body)
) {
const signedGBCS = await signGroupingHeader(
res.header.requestId?.originatorId,
res.body.ResponseMessage.PreCommand.GBCSPayload,
(eui, type, options) =>
ServerKeyStore(this, RED, eui, type, options),
)
const signedPrecommandDuis: SimplifiedDuisInput = {
header: {
type: 'request',
requestId: res.header.requestId,
commandVariant: 5,
serviceReference: res.body.ResponseMessage.ServiceReference,
serviceReferenceVariant:
res.body.ResponseMessage.ServiceReferenceVariant,
},
body: { SignedPreCommand: { GBCSPayload: signedGBCS } },
}
return asyncWorkerSend(
status,
signedPrecommandDuis,
'Send Command Service',
true,
)
}
RED.log.error(inspect(res, { depth: 10, colors: true }))
throw new Error('unexpected response from transform service')
}
return res
}
RED.httpNode.post(
this.config.responseEndpoint,
bodyParser.text({ inflate: true, type: 'application/xml' }),
(req, res, next) => {
if (typeof req.body !== 'string') {
next()
return
}
this.logger(req.body)
validateDuis({ xml: req.body })
.then((validated) => parseDuis('simplified', validated))
.then((duis) => {
if (isSimplifiedDuisOutputResponse(duis)) {
return duis
}
throw new Error('expected duis response')
})
.then((duis) => {
res.status(204)
res.send()
this.events.emit(
'duis',
duis,
this.messageStore.retrieve(duis.header.requestId),
)
})
.catch((e) => {
RED.log.warn('async response failed duis validation')
try {
/* if no listeners, error emitter throws error */
this.events.emit('error', e)
} catch {
RED.log.warn(e)
}
res.status(400)
res.send()
})
},
)
this.on('close', () => {
usedEndpoints.length = 0
this.events.removeAllListeners()
;(RED.httpNode._router.stack)?.forEach((layer, i, layers) => {
if (typeof layer === 'object' && layer !== null && 'route' in layer) {
const route = (
layer as {
route?: { path?: string; methods?: Record }
}
).route
if (
route?.path === this.config.responseEndpoint &&
route?.methods?.['post']
) {
layers.splice(i, 1)
}
}
})
if (this.logfile !== undefined) {
this.logfile.close()
}
})
this.publish = (nodeId, body) => {
const payload: WSMessageDTO = { id: this.id, sourceNode: nodeId, ...body }
RED.comms.publish(
`smartdcc/config/${this.id}/${body.kind}`,
payload,
false,
)
}
}
RED.nodes.registerType('dccboxed-config', ConfigConstruct)
}