netconf-client
Version:
581 lines • 27 kB
JavaScript
import { BehaviorSubject, catchError, combineLatest, EMPTY, filter, finalize, from, map, merge, NEVER, of, Subject, switchMap, take, takeUntil, tap, throwError, timeout, timer } from 'rxjs';
import { Client } from 'ssh2';
import * as xml2js from 'xml2js';
import { NETCONF_DELIM, NetconfBuffer } from "./netconf-buffer.js";
import { SSH_TIMEOUT } from "./netconf-types.js";
const NETCONF_DEBUG_LEVEL = 1;
const NETCONF_DEBUG_TAG = 'NETCONF';
const NETCONF_DATA_DEBUG_LEVEL = 2;
const NETCONF_DATA_DEBUG_RECV_TAG = 'RECV';
const NETCONF_DATA_DEBUG_SEND_TAG = 'SEND';
const NETCONF_HELLO_DEBUG_LEVEL = 3;
const SSH_DEBUG_TAG = 'SSH';
const SSH_DEBUG_LEVEL = 3;
const NOTIFICATION_REGEXP = new RegExp('<notification[\\s\\S]*</notification>');
export class NetconfClient {
/**
* Get the current connection state
*
* @returns {NetconfConnectionState} The current connection state
*/
get connectionState() {
return this.netconfChannelSubject$?.getValue()?.state ?? 'closed';
}
params;
sshClient;
idCounter = 0;
xmlBuilder;
xmlParserIgnoreAttrs;
xmlParser;
defaultIgnoreAttrs;
/**
* XML parser options
*/
xmlParserOptions = {
// Trim the whitespace at the beginning and end of text nodes
trim: true,
// Always put child nodes in an array if true; otherwise an array is created only if there is more than one
explicitArray: false,
// Ignore all XML attributes and only create text nodes
ignoreAttrs: false,
// Attribute value processing functions
attrValueProcessors: [xml2js.processors.parseNumbers],
// Element value processing functions
valueProcessors: [xml2js.processors.parseNumbers],
};
/**
* XML builder options
*/
xmlBuilderOptions = {
// Omit the XML header?
headless: false,
};
helloDataSubject$ = new BehaviorSubject(null);
/**
* Subject that stores and emits the netconf channel.
* The value is undefined until the channel is ready.
* The value is null if the channel is closed.
*/
netconfChannelSubject$ = new BehaviorSubject({ state: 'uninitialized' });
netconfChannel$ = of(null).pipe(switchMap(() => {
if (!this.netconfChannelSubject$) {
this.debug('netconfChannelSubject$ is undefined', NETCONF_DEBUG_TAG, NETCONF_DEBUG_LEVEL);
return throwError(() => new Error('Trying to use connection that was already closed'));
}
if (this.netconfChannelSubject$.getValue().state === 'uninitialized') {
this.debug(`Opening connection to ${this.params.user}@${this.params.host}:${this.params.port}`, NETCONF_DEBUG_TAG, NETCONF_DEBUG_LEVEL);
this.netconfChannelSubject$.next({ state: 'connecting' });
this.debug('Opening SSH session', NETCONF_DEBUG_TAG, NETCONF_DEBUG_LEVEL);
this.sshClient = new Client();
this.sshClient.on('ready', this.sshReadyEvent);
this.sshClient.on('error', this.sshErrorEvent);
this.sshClient.on('timeout', this.sshTimeoutEvent);
this.sshClient.on('close', this.sshCloseEvent);
this.sshClient.connect({
host: this.params.host,
username: this.params.user,
port: this.params.port,
password: this.params.pass,
readyTimeout: SSH_TIMEOUT,
debug: (message) => this.debug(message, SSH_DEBUG_TAG, SSH_DEBUG_LEVEL),
});
}
return this.netconfChannelSubject$.asObservable().pipe(filter(Boolean));
}));
/**
* @param {NetconfParams} params - Netconf client parameters
*/
constructor(params) {
this.params = params;
this.defaultIgnoreAttrs = this.params.ignoreAttrs;
this.xmlBuilder = new xml2js.Builder(this.xmlBuilderOptions);
// this.xmlParser = new xml2js.Parser(this.xmlParserOptions);
}
/**
* Close the Netconf channel and SSH connection.
*/
close() {
if (!this.netconfChannelSubject$) {
return throwError(() => new Error('Trying to close connection that was already closed'));
}
if (this.netconfChannelSubject$.getValue().state === 'uninitialized') {
return throwError(() => new Error('Trying to close connection that was not opened'));
}
// Get the Netconf channel from observable (replayed value)
return this.netconfChannel$.pipe(take(1),
// If timeout or error => no channel to close
timeout({
each: 1000,
with: () => of(null).pipe(tap(() => {
this.debug('Timeout waiting for Netconf channel when closing it', NETCONF_DEBUG_TAG, NETCONF_DEBUG_LEVEL);
})),
}), catchError((error) => {
this.debug(`Error getting Netconf channel while closing connection: ${error.message}`, NETCONF_DEBUG_TAG, NETCONF_DEBUG_LEVEL);
return of(null);
}),
// If channel opened, close it
switchMap((channelState) => {
if (!channelState) {
this.netconfChannelSubject$?.complete();
this.netconfChannelSubject$ = undefined;
return of(void 0);
}
switch (channelState.state) {
case 'uninitialized':
this.debug('Trying to close Netconf channel that was not initialized', NETCONF_DEBUG_TAG, NETCONF_DEBUG_LEVEL);
return throwError(() => new Error('Trying to close Netconf channel that was not initialized'));
case 'connecting':
this.debug('Closing Netconf channel that is being connected', NETCONF_DEBUG_TAG, NETCONF_DEBUG_LEVEL);
this.netconfChannelSubject$?.complete();
this.netconfChannelSubject$ = undefined;
return of(void 0);
case 'ready':
default:
this.debug('Closing Netconf channel, sending close-session', NETCONF_DEBUG_TAG, NETCONF_DEBUG_LEVEL);
return this.sendCloseSession(channelState.channel).pipe(catchError((error) => {
this.debug(`Error sending close-session: ${error.message}`, NETCONF_DEBUG_TAG, NETCONF_DEBUG_LEVEL);
return of(void 0);
}), tap(() => {
this.debug('Netconf channel closed', NETCONF_DEBUG_TAG, NETCONF_DEBUG_LEVEL);
this.netconfChannelSubject$?.getValue().channel?.removeAllListeners('close');
this.netconfChannelSubject$?.getValue().channel?.destroy();
this.netconfChannelSubject$?.complete();
this.netconfChannelSubject$ = undefined;
}),
// switchMap(() => channelClosed$),
take(1), timeout({
each: SSH_TIMEOUT,
with: () => of(void 0),
}), map(() => void 0), catchError((error) => {
this.debug(`Error closing Netconf channel: ${error.message}`, NETCONF_DEBUG_TAG, NETCONF_DEBUG_LEVEL);
return of(void 0);
}));
}
}), switchMap(() => {
if (!this.sshClient) {
this.debug('No SSH session to close', NETCONF_DEBUG_TAG, NETCONF_DEBUG_LEVEL);
return of(void 0);
}
this.debug('Closing SSH session', NETCONF_DEBUG_TAG, NETCONF_DEBUG_LEVEL);
const sessionClosed$ = new Subject();
const sessionClosedEvent = () => {
this.sshClient?.removeAllListeners('close');
this.sshClient?.removeAllListeners('error');
this.sshClient?.removeAllListeners('timeout');
this.sshClient?.destroy();
this.sshClient = undefined;
this.debug('SSH session closed', NETCONF_DEBUG_TAG, NETCONF_DEBUG_LEVEL);
sessionClosed$.next();
sessionClosed$.complete();
};
this.sshClient.removeListener('close', this.sshCloseEvent);
this.sshClient.on('close', sessionClosedEvent);
this.sshClient.end();
return sessionClosed$.pipe(take(1), catchError((error) => {
sessionClosedEvent();
this.debug(`Error closing SSH session: ${error.message}`, NETCONF_DEBUG_TAG, NETCONF_DEBUG_LEVEL);
return of(void 0);
}), timeout({
each: SSH_TIMEOUT,
with: () => {
sessionClosedEvent();
return throwError(() => new Error('Timeout closing SSH session'));
},
}));
}), finalize(() => {
this.debug('Connection closed', NETCONF_DEBUG_TAG, NETCONF_DEBUG_LEVEL);
}));
}
/**
* Observable that emits the hello message or an error
*/
hello() {
if (!this.netconfChannelSubject$) {
return throwError(() => new Error('Requesting HELLO on a closed connection'));
}
return merge(
// Error stream - completes when hello data arrives
this.netconfChannel$.pipe(takeUntil(this.helloDataSubject$.pipe(filter(Boolean))), switchMap(() => NEVER)),
// Hello data stream
this.helloDataSubject$.pipe(filter(Boolean))).pipe(take(1), finalize(() => {
this.debug('Finalize Hello data stream', NETCONF_DEBUG_TAG, NETCONF_DEBUG_LEVEL);
}));
}
/**
* Send a netconf RPC request.
*
* Send a request and wait for a response. The first response is emitted and the observable completes.
*
* @param {NetconfObjType} request - The netconf request object
* @returns {Observable<NetconfData>} An observable that emits the response
*/
rpcExec(request, ignoreAttrs) {
return this.sendRequest(request, undefined, ignoreAttrs);
}
/**
* Send a request and wait for notifications. The returned observable will continue to emit
* notifications as they are received. Used for subscriptions.
*
* @param {NetconfObjType} request - The netconf request object
* @param {Observable<void>} stop$ - An observable that emits when the request should be stopped
* @returns {Observable<NetconfData>} An observable that emits the response
*/
rpcStream(request, stop$) {
return this.sendRequest(request, stop$).pipe(
// Check if result is OK
tap((data) => {
if (data.result?.hasOwnProperty('rpc-reply')) {
const rpcReply = data.result;
if (!rpcReply['rpc-reply']?.hasOwnProperty('ok')) {
throw new Error('Did not receive rpc-reply/ok in response to subscription');
}
}
}), catchError((err) => {
this.handleError(err);
return EMPTY;
}));
}
debug(message, tag, level) {
if (this.params.debug) {
this.params.debug(`${tag}: ${message}`, level ?? NETCONF_DEBUG_LEVEL);
}
}
/**
* Send a netconf request and return an observable that emits the response(s).
*
* @param {NetconfObjType} request - The netconf request object
* @param {Observable<void>} stop$ - Controls the subscription behavior:
* - If undefined: Single-response mode. Emits first server response and completes immediately after.
* - If provided: Subscription mode. Continuously emits notifications from server and completes when stop$ emits.
* @returns {Observable<NetconfData>} Server response(s):
* - Single response in single-response mode
* - Stream of notifications in subscription mode
*/
sendRequest(request, stop$, ignoreAttrs) {
return of(null).pipe(map(() => {
const messageId = this.idCounter += 1;
const object = {};
object.rpc = request;
if (!object.rpc.$)
object.rpc.$ = {};
object.rpc.$['message-id'] = messageId;
object.rpc.$.xmlns = 'urn:ietf:params:xml:ns:netconf:base:1.0';
return [messageId, this.xmlBuilder.buildObject(object)];
}), map(([messageId, xml]) => [messageId, `${xml}\n${NETCONF_DELIM}`]), switchMap(([messageId, xml]) => this.sendXml(xml, messageId, stop$, ignoreAttrs)),
// Timeout only for the first request. All subsequent requests (in case of awaitNotifications) are notifications
// and can be received for a long time.
timeout({
first: SSH_TIMEOUT,
with: () => throwError(() => new Error('Timeout sending request')),
}), catchError(err => {
this.handleError(err);
return throwError(() => err);
}));
}
sendXml(xml, messageId, stop$, ignoreAttrs) {
if (!this.netconfChannelSubject$) {
return throwError(() => new Error('Requesting data on a closed connection'));
}
const rcvBuffer = new NetconfBuffer();
const replySubject = new Subject();
let replyReceived = false;
const rpcReplyRegexp = new RegExp(`<rpc-reply.*message-id="${messageId}"[\\s\\S]*</rpc-reply>`);
const dataEventHandler = (data) => {
this.debug(`Data received (expecting rpc-reply), len=${data.length}`, NETCONF_DEBUG_TAG, NETCONF_DATA_DEBUG_LEVEL);
if (!rcvBuffer.append(data)) {
rcvBuffer.clear();
replySubject.complete();
this.handleError(new Error('Netconf message too large'));
return;
}
let message;
while ((message = rcvBuffer.extract()) !== undefined) {
this.debug(message, NETCONF_DATA_DEBUG_RECV_TAG, NETCONF_DATA_DEBUG_LEVEL);
if ((!replyReceived && rpcReplyRegexp.test(message))
|| (replyReceived && stop$ && NOTIFICATION_REGEXP.test(message))) {
if (!replyReceived) {
this.debug(`Received reply, message-id=${messageId}`, NETCONF_DEBUG_TAG, NETCONF_DEBUG_LEVEL);
}
else {
this.debug('Received notification', NETCONF_DEBUG_TAG, NETCONF_DEBUG_LEVEL);
}
replyReceived = true;
this.parseXml(message, ignoreAttrs).pipe(catchError((err) => {
this.handleError(err);
return EMPTY;
})).subscribe({
next: ({ parsed, original }) => {
replySubject.next({ xml: original, result: parsed });
if (!stop$) {
replySubject.complete();
}
else {
this.debug('Waiting for notifications', NETCONF_DEBUG_TAG, NETCONF_DATA_DEBUG_LEVEL);
}
},
});
if (!stop$) {
break;
}
}
}
};
return combineLatest([
// Error stream - completes when hello data arrives
this.netconfChannel$.pipe(takeUntil(replySubject), filter(channelState => channelState.state === 'ready'), tap((channelState) => {
this.debug(`Sending request, message-id=${messageId}`, NETCONF_DEBUG_TAG, NETCONF_DEBUG_LEVEL);
this.debug(xml, NETCONF_DATA_DEBUG_SEND_TAG, NETCONF_DATA_DEBUG_LEVEL);
channelState.channel?.write(xml, () => channelState.channel?.on('data', dataEventHandler));
})),
// Data stream
replySubject,
]).pipe(takeUntil(stop$?.pipe(tap(() => {
this.debug(`Removing data event listener for message-id=${messageId}`, NETCONF_DEBUG_TAG, NETCONF_DEBUG_LEVEL);
this.netconfChannelSubject$?.getValue().channel?.removeListener('data', dataEventHandler);
rcvBuffer.clear();
replySubject.complete();
})) ?? NEVER), map(([channelState, data]) => {
if (!stop$) {
this.debug(`Removing data event listener for message-id=${messageId}`, NETCONF_DEBUG_TAG, NETCONF_DATA_DEBUG_LEVEL);
channelState.channel?.removeListener('data', dataEventHandler);
rcvBuffer.clear();
}
return data;
}));
}
/**
* Handles the ssh ready event from the ssh lib. Event is sent by the ssh lib when the connection is ready,
* but not the netconf channel yet.
*/
sshReadyEvent = () => {
this.debug('SSH session ready', NETCONF_DEBUG_TAG, NETCONF_DEBUG_LEVEL);
this.debug('Opening Netconf channel', NETCONF_DEBUG_TAG, NETCONF_DEBUG_LEVEL);
this.sshClient?.subsys('netconf', this.channelReady);
timer(SSH_TIMEOUT).pipe(takeUntil(this.helloDataSubject$.pipe(filter(Boolean)))).subscribe(() => {
this.debug('Timeout waiting for HELLO', NETCONF_DEBUG_TAG, NETCONF_DEBUG_LEVEL);
this.handleError(new Error('Netconf HELLO not received'));
});
};
/**
* Handles the ssh error event from the ssh lib.
*
* @param {Error} err - The error event
*/
sshErrorEvent = (err) => {
this.debug(`SSH session error: ${err.message}`, NETCONF_DEBUG_TAG, NETCONF_DEBUG_LEVEL);
this.handleError(err);
};
/**
* Handles the ssh timeout event from the ssh lib.
*/
sshTimeoutEvent = () => {
this.debug('SSH session timeout', NETCONF_DEBUG_TAG, NETCONF_DEBUG_LEVEL);
this.handleError(new Error('SSH timeout'));
};
/**
* Handles the ssh close event. Event is sent by the ssh lib when the connection is closed.
*/
sshCloseEvent = () => {
this.debug('SSH session closed unexpectedly', NETCONF_DEBUG_TAG, NETCONF_DEBUG_LEVEL);
this.handleError(new Error('SSH session closed unexpectedly'));
};
/** Clean up on error and send the error to the connection subject */
handleError(err) {
const channel = this.netconfChannelSubject$?.getValue().channel;
if (channel) {
channel.removeAllListeners('close');
channel.removeAllListeners('error');
channel.destroy();
}
if (this.sshClient) {
this.sshClient.removeAllListeners('error');
this.sshClient.removeAllListeners('timeout');
this.sshClient.removeAllListeners('close');
this.sshClient.destroy();
}
this.netconfChannelSubject$?.error(err);
}
/**
* Handles the channel ready event. Event is sent by the ssh lib when the Netconf channel is ready.
*
* @param {Error | undefined} err - The error event
* @param {ClientChannel} channel - The opened channel (Netconf)
*/
channelReady = (err, channel) => {
if (err) {
this.debug(`Netconf channel error: ${err.message}`, NETCONF_DEBUG_TAG, NETCONF_DEBUG_LEVEL);
this.handleError(err);
return;
}
this.debug('Netconf channel ready', NETCONF_DEBUG_TAG, NETCONF_DEBUG_LEVEL);
const rcvBuffer = new NetconfBuffer();
const cleanup = () => {
channel.removeListener('data', helloListener);
channel.removeListener('close', closeListener);
channel.removeListener('error', errorListener);
rcvBuffer.clear();
};
const helloListener = (chunk) => {
this.channelHelloEvent(channel, chunk, rcvBuffer, helloListener);
};
const closeListener = () => {
this.debug('Netconf channel closed unexpectedly', NETCONF_DEBUG_TAG, NETCONF_DEBUG_LEVEL);
cleanup();
this.handleError(new Error('Netconf channel closed unexpectedly'));
};
const errorListener = (error) => {
this.debug(`Netconf channel error: ${error.message}`, NETCONF_DEBUG_TAG, NETCONF_DEBUG_LEVEL);
cleanup();
this.handleError(error);
};
channel.on('data', helloListener);
channel.on('error', errorListener);
channel.on('close', closeListener);
// timer(NETCONF_HELLO_TIMEOUT).pipe(
// takeUntil(this.helloDataSubject$.pipe(filter(Boolean))),
// ).subscribe(() => {
// this.debug('Timeout waiting for HELLO', NETCONF_DEBUG_TAG, NETCONF_DEBUG_LEVEL);
// channel.removeListener('data', helloListener);
// channel.removeListener('error', errorListener);
// channel.removeListener('close', closeListener);
// this.handleError(new Error('Netconf HELLO not received'));
// });
this.sendHello(channel);
};
/**
* Sends the hello message to the netconf server.
*/
sendHello(channel) {
const message = {
hello: {
$: {
xmlns: 'urn:ietf:params:xml:ns:netconf:base:1.0',
},
capabilities: {
capability: ['urn:ietf:params:xml:ns:netconf:base:1.0', 'urn:ietf:params:netconf:base:1.0'],
},
},
};
const xml = `${this.xmlBuilder.buildObject(message)}\n${NETCONF_DELIM}`;
this.debug(xml, NETCONF_DATA_DEBUG_SEND_TAG, NETCONF_HELLO_DEBUG_LEVEL);
channel.write(xml);
}
/**
* Handles the first data event from the netconf channel.
* The first (or few first - depending on the number of chunks) data event is the hello message.
*
* @param {ClientChannel} channel - The netconf channel
* @param {Buffer} chunk - The chunk of data received from the netconf channel
* @param {Buffer} rcvBuffer - The buffer to store the received data
* @param {Function} helloListenerRef - The reference to the wrapper data listener function
*/
channelHelloEvent(channel, chunk, rcvBuffer, helloListenerRef) {
this.debug(`Data received (expecting hello), length=${chunk.length}`, NETCONF_DEBUG_TAG, NETCONF_DATA_DEBUG_LEVEL);
if (!rcvBuffer.append(chunk)) {
rcvBuffer.clear();
channel.removeAllListeners();
channel.destroy();
this.handleError(new Error('Netconf message too large'));
return;
}
let message;
while ((message = rcvBuffer.extract()) !== undefined) {
this.debug(message, NETCONF_DATA_DEBUG_RECV_TAG, NETCONF_HELLO_DEBUG_LEVEL);
this.parseXml(message).subscribe({
next: ({ parsed, original }) => {
if (parsed.hasOwnProperty('hello') && parsed.hello?.hasOwnProperty('session-id')) {
const hello = parsed;
this.debug(`HELLO received, Netconf session ID: ${hello.hello['session-id']}`, NETCONF_DEBUG_TAG, NETCONF_DEBUG_LEVEL);
rcvBuffer.clear();
if (this.netconfChannelSubject$?.getValue().state !== 'ready') {
this.debug('Connection ready', NETCONF_DEBUG_TAG, NETCONF_DEBUG_LEVEL);
this.netconfChannelSubject$?.next({ state: 'ready', channel });
this.helloDataSubject$.next({ xml: original, result: hello });
}
channel.removeListener('data', helloListenerRef);
}
},
error: (err) => {
this.debug(`Error parsing HELLO response: ${err.message}`, NETCONF_DEBUG_TAG, NETCONF_DEBUG_LEVEL);
rcvBuffer.clear();
channel.removeAllListeners();
channel.destroy();
this.handleError(err);
},
});
}
}
sendCloseSession(channel) {
channel.removeAllListeners('close');
return this.sendRequest({
'close-session': {
$: { xmlns: 'urn:ietf:params:xml:ns:netconf:base:1.0' },
},
});
}
/**
* Converts Netconf XML response into a NetconfType object
*
* @param {string} xml - XML string to parse
* @return {Observable<NetconfType>} Observable that emits the object parsed from the XML
*/
parseXml(xml, ignoreAttrs) {
if (ignoreAttrs === undefined) {
ignoreAttrs = this.defaultIgnoreAttrs;
}
let xmlParser;
if (ignoreAttrs) {
if (!this.xmlParserIgnoreAttrs) {
this.xmlParserIgnoreAttrs = new xml2js.Parser({ ...this.xmlParserOptions, ignoreAttrs: true });
}
xmlParser = this.xmlParserIgnoreAttrs;
}
else {
if (!this.xmlParser) {
this.xmlParser = new xml2js.Parser({ ...this.xmlParserOptions, ignoreAttrs: false });
}
xmlParser = this.xmlParser;
}
return from(xmlParser.parseStringPromise(xml)).pipe(map(reply => {
// check if there is an error in the rpc reply
if (reply.hasOwnProperty('rpc-reply')) {
const errorMessage = this.hasError(reply['rpc-reply']);
if (errorMessage) {
throw new Error(`Netconf RPC error: ${errorMessage}`);
}
}
return { parsed: reply, original: xml };
}));
}
/**
* Checks if the rpc reply has an error and returns the error message
*
* @param rpcReply - The rpc reply to check
* @returns The error message if there is an error, otherwise undefined
*/
hasError(rpcReply) {
const rpcError = rpcReply['rpc-error'];
if (!rpcError)
return undefined;
const errorMessage = rpcError['error-message'];
if (typeof errorMessage === 'object') {
return errorMessage?._ ?? rpcError['error-tag'];
}
else if (typeof errorMessage === 'string') {
return errorMessage;
}
else {
switch (rpcError['error-tag']) {
case 'unknown-element':
return `Unknown element: "${rpcError['error-info']?.['bad-element'] ?? ''}". Do you need to provide a namespace?`;
case 'unknown-namespace':
return `Unknown namespace: "${rpcError['error-info']?.['bad-namespace'] ?? ''}"`;
case 'data-exists':
return `Trying to create data that already exists (element "${rpcError['error-info']?.['bad-element'] ?? ''}")`;
default:
return rpcError['error-tag'];
}
}
}
}
//# sourceMappingURL=netconf-client.js.map