netconf-client
Version:
500 lines • 21.7 kB
JavaScript
import { catchError, endWith, map, of, switchMap, tap, throwError } from 'rxjs';
import { NetconfBuildConfig } from "./netconf-build-config.js";
import { NetconfClient } from "./netconf-client.js";
import { GetDataResultType, MultipleEditError } from "./netconf-types.js";
const NETCONF_DEBUG_LEVEL = 1;
const NETCONF_DEBUG_TAG = 'NETCONF';
/**
* Netconf client
*/
export class Netconf extends NetconfClient {
/**
* Class constructor
*
* @param {NetconfParams} params - Netconf client parameters
*/
constructor(params) {
super(params);
}
/**
* Execute a custom RPC operation. Example:
*
* ```typescript
* const netconf = new Netconf({
* host: '127.0.0.1',
* port: 2022,
* user: 'admin',
* pass: 'admin',
* namespace: 'urn:ietf:params:xml:ns:yang:ietf-netconf-monitoring',
* });
* netconf.rpc('/get-schema', { identifier: 'ietf-netconf' }).subscribe({
* next: (data) => {
* console.log(data);
* },
* error: (error) => {
* console.error(error);
* },
* });
* ```
*
* @param {string} xpath - RPC command formatted as XPath
* @param {NetconfType} values - Object of key-value pairs to be sent with the RPC request. The object may have
* nested objects and arrays.
* @returns {Observable<RpcResult>} Observable of the result
*/
rpc(xpath, values) {
xpath = xpath.trim();
if (xpath === '' || xpath === '/' || xpath === '//') {
return throwError(() => new Error('XPath for rpc config must contain at least one element'));
}
const targetObj = {};
return new NetconfBuildConfig(xpath, undefined, this.params.namespace).build(targetObj).pipe(this.checkMultipleEdit(), tap((configObj) => {
configObj.forEach((o) => {
Object.assign(o, values);
});
}), switchMap(() => {
if (this.params.readOnly) {
this.debug('Read-only mode. Would send the following request to the server:', NETCONF_DEBUG_TAG, NETCONF_DEBUG_LEVEL);
this.debug(JSON.stringify(targetObj, null, 2), NETCONF_DEBUG_TAG, NETCONF_DEBUG_LEVEL);
throw new Error('Operation not performed: in read-only mode');
}
else {
return this.rpcExec(targetObj);
}
}), map((data) => ({
xml: data.xml,
result: data.result?.['rpc-reply'],
})));
}
/**
* Get items from the Netconf server using `get-data` or `get` RPC
*
* @param {string} xpath - XPath filter of the configuration object,
* for example, //aaa//user[name="admin"]
* @param {GetDataResultType} resultType Result type (config filter)
* - If 'config', only the configuration is returned
* - If 'state', only the state data is returned
* - If 'schema', only the shallow subtree for the XPath is returned (object with keys only), no descendants
* - If undefined, both config and state data are returned
* @returns {Observable<GetDataResult>} Observable of the result
*/
getData(xpath, resultType) {
let request;
switch (resultType) {
case GetDataResultType.SCHEMA:
request = {
'get-data': {
$: {
xmlns: 'urn:ietf:params:xml:ns:yang:ietf-netconf-nmda',
'xmlns:ds': 'urn:ietf:params:xml:ns:yang:ietf-datastores',
},
datastore: 'ds:operational',
'xpath-filter': xpath,
'max-depth': 1,
},
};
break;
case GetDataResultType.CONFIG:
case GetDataResultType.STATE:
// ConfD specific get-data RPC (part of ConfD's tailf namespace)
request = {
'get-data': {
$: {
xmlns: 'urn:ietf:params:xml:ns:yang:ietf-netconf-nmda',
'xmlns:ds': 'urn:ietf:params:xml:ns:yang:ietf-datastores',
},
datastore: 'ds:operational',
'xpath-filter': xpath,
'with-defaults': 'report-all',
'config-filter': resultType === GetDataResultType.CONFIG ? true : false,
},
};
break;
// If undefined, both config and state data are returned
default:
// Standard NETCONF operation, retrieves both config and state data
request = {
get: {
$: {
xmlns: 'urn:ietf:params:xml:ns:netconf:base:1.0',
},
'with-defaults': {
$: {
xmlns: 'urn:ietf:params:xml:ns:yang:ietf-netconf-with-defaults',
},
_: 'report-all',
},
filter: {
$: {
type: 'xpath',
select: xpath,
},
},
},
// Also possible to use `get-data` RPC:
// 'get-data': {
// $: {
// xmlns: 'urn:ietf:params:xml:ns:yang:ietf-netconf-nmda',
// 'xmlns:ds': 'urn:ietf:params:xml:ns:yang:ietf-datastores',
// },
// datastore: 'ds:operational',
// 'xpath-filter': xpath,
// 'with-defaults': 'report-all',
// },
};
break;
}
return this.rpcExec(request, resultType === GetDataResultType.SCHEMA ? false : undefined).pipe(map((data) => {
const ret = {
xml: data.xml,
result: data.result,
};
if (!data.result?.hasOwnProperty('rpc-reply')) {
ret.result = undefined;
return ret;
}
const rpcReply = data.result?.['rpc-reply'];
ret.result = rpcReply.data;
if (resultType === GetDataResultType.SCHEMA && ret.result?.hasOwnProperty('$')) {
delete ret.result.$;
}
return ret;
}));
}
/**
* Edit the configuration using `edit-config` RPC and merge operation
*
* @param {string} xpath XPath filter of the configuration object,
* for example, `/aaa/authentication/users/user[name="admin"]`
* @param {NetconfType} values New config object - a key-value pair object that can have
* nested objects, for example `{homedir: '/home/admin'}`
* @returns {Observable<EditConfigResult>} Observable of the result
*/
editConfigMerge(xpath, values) {
const schema = this.fetchSchema(xpath);
const targetObj = {};
return new NetconfBuildConfig(xpath, schema, this.params.namespace, this.guessNamespace(xpath)).build(targetObj).pipe(this.checkMultipleEdit(), tap((configObj) => {
configObj.forEach((o) => {
Object.assign(o, values);
});
}), switchMap(() => this.editConfig(targetObj)));
}
/**
* Creates a leaf in the configuration specified by XPath filter.
* Default operation is 'create', so if an item exists, confd will return error.
* If beforeKey is specified, the item will be inserted before the specified item.
*
* @param {string} xpath XPath filter of the leaf where the item needs to be inserted
* for example, /aaa/authentication/users/user
* @param {NetconfType} values New item config, for example {name: 'admin', homedir: '/home/admin'}
* @param {string} beforeKey If specified, the item will be inserted before the item specified by this key
* for example, '[name="oper"]'
* @returns {Observable<EditConfigResult>} Observable of the result
*/
editConfigCreate(xpath, values, beforeKey) {
const targetObj = {};
const schema = this.fetchSchema(xpath);
return new NetconfBuildConfig(xpath, schema, this.params.namespace, this.guessNamespace(xpath)).build(targetObj).pipe(this.checkMultipleEdit(), tap((configObj) => {
configObj.forEach((o) => {
Object.assign(o, values);
o.$ = {
...o.$ ?? {},
'xmlns:nc': 'urn:ietf:params:xml:ns:netconf:base:1.0',
'nc:operation': 'create',
};
if (beforeKey !== undefined) {
o.$ = {
...o.$ ?? {},
'xmlns:yang': 'urn:ietf:params:xml:ns:yang:1',
'yang:insert': 'before',
'yang:key': beforeKey,
};
}
});
}), switchMap(() => this.editConfig(targetObj)));
}
/**
* Creates a list item in the configuration.
*
* @param {string} xpath XPath filter of the object where the item needs to be created
* @param {NetconfPrimitiveType[]} listItems List of items to create
* @returns {Observable<EditConfigResult>} Observable of the result
*/
editConfigCreateListItems(xpath, listItems) {
const targetObj = {};
const schema = this.fetchSchema(xpath);
return new NetconfBuildConfig(xpath, schema, this.params.namespace, this.guessNamespace(xpath)).build(targetObj).pipe(this.checkMultipleEdit(), tap((configObj) => {
configObj.forEach((o) => {
// Traverse the targetObj to find the parent of o
const foundParent = this.findParent(targetObj, o);
if (foundParent === undefined) {
throw new Error('Failed to build the edit config message matching the XPath/Schema');
}
const parent = foundParent.parent;
const index = foundParent.index;
parent[index] = listItems.map((value) => ({
$: {
'xmlns:nc': 'urn:ietf:params:xml:ns:netconf:base:1.0',
'nc:operation': 'create',
},
_: value,
}));
});
}), switchMap(() => this.editConfig(targetObj)));
}
/**
* Deletes a leaf in the configuration. Leaf is specified by XPath filter.
*
* @param {string} xpath XPath filter of the leaf where the item needs to be deleted
* for example, `/aaa/authentication/users/user`
* @param {NetconfType} values object containing the leaf key, for example `{name: 'admin'}`
* @returns {Observable<EditConfigResult>} Observable of the result
*/
editConfigDelete(xpath, values) {
const targetObj = {};
const schema = this.fetchSchema(xpath);
return new NetconfBuildConfig(xpath, schema, this.params.namespace, this.guessNamespace(xpath)).build(targetObj).pipe(this.checkMultipleEdit(), tap((configObj) => {
configObj.forEach((o) => {
Object.assign(o, values);
o.$ = {
...o.$ ?? {},
'xmlns:nc': 'urn:ietf:params:xml:ns:netconf:base:1.0',
'nc:operation': 'delete',
};
});
}), switchMap(() => this.editConfig(targetObj)));
}
/**
* Deletes a list item in the configuration.
*
* @param {string} xpath XPath filter of the object where the item needs to be deleted
* @param {NetconfPrimitiveType[]} listItems List of items to delete
* @returns {Observable<EditConfigResult>} Observable of the result
*/
editConfigDeleteListItems(xpath, listItems) {
const targetObj = {};
const schema = this.fetchSchema(xpath);
return new NetconfBuildConfig(xpath, schema, this.params.namespace, this.guessNamespace(xpath)).build(targetObj).pipe(this.checkMultipleEdit(), tap((configObj) => {
configObj.forEach((o) => {
const foundParent = this.findParent(targetObj, o);
if (foundParent === undefined) {
throw new Error('Failed to build the edit config message matching the XPath/Schema');
}
const parent = foundParent.parent;
const index = foundParent.index;
parent[index] = listItems.map((value) => ({
$: {
'xmlns:nc': 'urn:ietf:params:xml:ns:netconf:base:1.0',
'nc:operation': 'delete',
},
_: value,
}));
});
}), switchMap(() => this.editConfig(targetObj)));
}
/**
* Replaces a leaf in the configuration. Leaf is specified by XPath filter.
*
* @param {string} xpath XPath filter of the leaf where the item needs to be replaced
* for example, `/aaa/authentication/users/user`
* @param {NetconfType} values object containing the leaf key, for example `{name: 'admin'}`
* @returns {Observable<EditConfigResult>} Observable of the result
*/
editConfigReplace(xpath, values) {
const targetObj = {};
const schema = this.fetchSchema(xpath);
return new NetconfBuildConfig(xpath, schema, this.params.namespace, this.guessNamespace(xpath)).build(targetObj).pipe(this.checkMultipleEdit(), tap((configObj) => {
configObj.forEach((o) => {
Object.assign(o, values);
o.$ = {
...o.$ ?? {},
'xmlns:nc': 'urn:ietf:params:xml:ns:netconf:base:1.0',
'nc:operation': 'replace',
};
});
}), switchMap(() => this.editConfig(targetObj)));
}
/**
* Replaces a list item in the configuration.
*
* @param {string} xpath XPath filter of the object where the item needs to be replaced
* @param {NetconfPrimitiveType[]} listItems List of items to replace
* @returns {Observable<EditConfigResult>} Observable of the result
*/
editConfigReplaceListItems(xpath, listItems) {
const targetObj = {};
const schema = this.fetchSchema(xpath);
return new NetconfBuildConfig(xpath, schema, this.params.namespace, this.guessNamespace(xpath)).build(targetObj).pipe(this.checkMultipleEdit(), tap((configObj) => {
configObj.forEach((o) => {
const foundParent = this.findParent(targetObj, o);
if (foundParent === undefined) {
throw new Error('Failed to build the edit config message matching the XPath/Schema');
}
const parent = foundParent.parent;
const index = foundParent.index;
parent[index] = listItems.map((value) => ({
$: {
'xmlns:nc': 'urn:ietf:params:xml:ns:netconf:base:1.0',
'nc:operation': 'replace',
},
_: value,
}));
});
}), switchMap(() => this.editConfig(targetObj)));
}
/**
* Creates a subscription to the Netconf server and returns an observable that emits notifications as they arrive.
* See README for an example.
*
* @param {SubscriptionOption} option Subscription option - either xpath filter or stream name
* @param {Subject<void>} stop$ A subject that should emit a value to stop the subscription
* @returns {Observable<NotificationResult | RpcResult | undefined>} Observable of the result. Observable emits
* the following items in order:
* - `RpcResult` with the server's RPC response when the subscription is created
* - `NotificationResult` when a notification is received
* - `undefined` when the subscription is stopped
*/
subscription(option, stop$) {
let request;
if (typeof option === 'object' && 'xpath' in option) {
request = {
'create-subscription': {
$: {
xmlns: 'urn:ietf:params:xml:ns:netconf:notification:1.0',
},
filter: {
$: {
type: 'xpath',
select: option.xpath,
},
},
},
};
}
else if (typeof option === 'object' && 'stream' in option) {
request = {
'create-subscription': {
$: {
xmlns: 'urn:ietf:params:xml:ns:netconf:notification:1.0',
},
stream: option.stream,
},
};
}
else {
throw new Error('Invalid option in subscription');
}
return this.rpcStream(request, stop$).pipe(map((data) => {
if (data.result?.hasOwnProperty('rpc-reply')) {
const rpcResult = {
xml: data.xml,
result: data.result['rpc-reply'],
};
return rpcResult;
}
return data;
}),
// When subscription is closed, emit undefined
endWith(undefined));
}
fetchSchema(xpath) {
return this.getData(xpath, GetDataResultType.SCHEMA).pipe(map((data) => {
if (data.result === undefined) {
throw new Error('Failed to fetch element matching the XPath from the server. No element to update.');
}
return data.result;
}));
}
guessNamespace(xpath) {
// get first XPath segment
const firstSegment = xpath.trim().replace('^//', '/').split('/').find(x => x !== '');
if (firstSegment === undefined) {
return of(undefined);
}
return this.getData(`/${firstSegment}`, GetDataResultType.SCHEMA).pipe(map((data) => {
const result = data.result;
if (Object.keys(result).length === 1 && result[Object.keys(result)[0]]?.hasOwnProperty('$')) {
const $ = result[Object.keys(result)[0]]?.$;
if ($.hasOwnProperty('xmlns')) {
return $.xmlns;
}
}
return undefined;
}), catchError(() => of(undefined)));
}
editConfig(configObj) {
const request = {
'edit-config': {
target: {
running: null,
},
config: configObj,
},
};
if (this.params.readOnly) {
this.debug('Read-only mode. Would send the following request to the server:', NETCONF_DEBUG_TAG, NETCONF_DEBUG_LEVEL);
this.debug(JSON.stringify(request, null, 2), NETCONF_DEBUG_TAG, NETCONF_DEBUG_LEVEL);
throw new Error('Operation not performed: in read-only mode');
}
return this.rpcExec(request).pipe(map((data) => {
const ret = {
xml: data.xml,
result: data.result,
};
if (!data.result?.hasOwnProperty('rpc-reply')) {
throw new Error('Edit-config operation failed: server response did not include rpc-reply');
}
const result = data.result;
if (result['rpc-reply']?.ok === undefined) {
throw new Error('Edit-config operation failed: server response did not include OK confirmation');
}
ret.result = result['rpc-reply'];
return ret;
}));
}
checkMultipleEdit() {
return tap((configObj) => {
if (configObj.length === 0) {
// throw an error
throw new Error('Failed to build the edit config message matching the XPath/Schema');
}
else if (configObj.length > 1 && !this.params.allowMultipleEdit) {
// throw an error
throw new MultipleEditError();
}
});
}
/**
* Given an object and one of its children, recursively traverse the object to find the parent of the given child
* @param root - The root object
* @param obj - The child object
* @returns The child's parent object
*/
// eslint-disable-next-line sonarjs/cognitive-complexity
findParent(root, obj) {
if (Array.isArray(root)) {
for (let i = 0; i < root.length; i++) {
if (root[i] === obj) {
return { parent: root, index: i };
}
const ret = this.findParent(root[i], obj);
if (ret)
return ret;
}
}
if (typeof root === 'object' && root !== null) {
for (const key of Object.keys(root)) {
if (root.hasOwnProperty(key)) {
if (root[key] === obj) {
return { parent: root, index: key };
}
const ret = this.findParent(root[key], obj);
if (ret)
return ret;
}
}
}
return undefined;
}
}
//# sourceMappingURL=netconf.js.map