diff --git a/README.md b/README.md index d69e3d2..4485c84 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ from cisco_gnmi import ClientBuilder client = ClientBuilder( '127.0.0.1:9339' -).set_os('IOS XR').set_secure_from_file( +).set_os('IOS XE').set_secure_from_file( root_certificates='rootCA.pem', private_key='client.key', certificate_chain='client.crt', @@ -125,10 +125,20 @@ client = ClientBuilder( Methods are documented in [`src/cisco_gnmi/client.py`](src/cisco_gnmi/client.py). +### NXClient +`NXClient` inherits from `Client` and provides several wrapper methods which aid with NX-OS gNMI implementation usage. These are `subscribe_xpaths`, and the removal of `get` and `set` as they are not yet supported operations. These methods have some helpers and constraints around what is supported by the implementation. + +Methods and usage examples are documented in [`src/cisco_gnmi/nx.py`](src/cisco_gnmi/nx.py). + +### XEClient +`XEClient` inherits from `Client` and provides several wrapper methods which aid with IOS XE gNMI implementation usage. These are `delete_xpaths`, `get_xpaths`, `set_json`, and `subscribe_xpaths`. These methods have some helpers and constraints around what is supported by the implementation. + +Methods and usage examples are documented in [`src/cisco_gnmi/xe.py`](src/cisco_gnmi/xe.py). + ### XRClient -`XRClient` inherets from `Client` and provides several wrapper methods which aid with IOS XR-specific behaviors of the gNMI implementation. These are `delete_xpaths`, `get_xpaths`, `set_json`, and `subscribe_xpaths`. These methods make several assumptions about what kind of information will be supplied to them in order to simplify usage of the gNMI RPCs, detailed in the documentation. +`XRClient` inherits from `Client` and provides several wrapper methods which aid with IOS XR gNMI implementation usage. These are `delete_xpaths`, `get_xpaths`, `set_json`, and `subscribe_xpaths`. These methods have some helpers and constraints around what is supported by the implementation. -Methods are documented in [`src/cisco_gnmi/xr.py`](src/cisco_gnmi/xr.py). +Methods and usage examples are documented in [`src/cisco_gnmi/xr.py`](src/cisco_gnmi/xr.py). ## gNMI gRPC Network Management Interface (gNMI) is a service defining an interface for a network management system (NMS) to interact with a network element. It may be thought of as akin to NETCONF or other control protocols which define operations and behaviors. The scope of gNMI is relatively simple - it seeks to "[[define](https://github.com/openconfig/reference/blob/master/rpc/gnmi/gnmi-specification.md)] a gRPC-based protocol for the modification and retrieval of configuration from a target device, as well as the control and generation of telemetry streams from a target device to a data collection system. The intention is that a single gRPC service definition can cover both configuration and telemetry - allowing a single implementation on the target, as well as a single NMS element to interact with the device via telemetry and configuration RPCs". diff --git a/scripts/.gitignore b/scripts/.gitignore new file mode 100644 index 0000000..a2661ad --- /dev/null +++ b/scripts/.gitignore @@ -0,0 +1 @@ +certs/ \ No newline at end of file diff --git a/scripts/gen_certs.sh b/scripts/gen_certs.sh new file mode 100755 index 0000000..a15f10f --- /dev/null +++ b/scripts/gen_certs.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Derived from https://www.cisco.com/c/en/us/td/docs/ios-xml/ios/prog/configuration/1612/b_1612_programmability_cg/grpc_network_management_interface.html#id_89031 + +CERT_BASE="certs" + +if [ -z $1 ]; then + echo "Usage: gen_certs.sh []" + exit 1 +fi + +mkdir -p $CERT_BASE + +# Setting up a CA +openssl genrsa -out $CERT_BASE/rootCA.key 2048 +openssl req -subj /C=/ST=/L=/O=/CN=rootCA -x509 -new -nodes -key $CERT_BASE/rootCA.key -sha256 -out $CERT_BASE/rootCA.pem + +# Setting up device cert and key +openssl genrsa -out $CERT_BASE/device.key 2048 +openssl req -subj /C=/ST=/L=/O=/CN=$1 -new -key $CERT_BASE/device.key -out $CERT_BASE/device.csr +openssl x509 -req -in $CERT_BASE/device.csr -CA $CERT_BASE/rootCA.pem -CAkey $CERT_BASE/rootCA.key -CAcreateserial -out $CERT_BASE/device.crt -sha256 + +# Encrypt device key - needed for input to IOS +if [ ! -z $2 ]; then + openssl rsa -des3 -in $CERT_BASE/device.key -out $CERT_BASE/device.des3.key -passout pass:$2 +else + echo "Skipping device key encryption." +fi + +# Setting up client cert and key +openssl genrsa -out $CERT_BASE/client.key 2048 +openssl req -subj /C=/ST=/L=/O=/CN=gnmi_client -new -key $CERT_BASE/client.key -out $CERT_BASE/client.csr +openssl x509 -req -in $CERT_BASE/client.csr -CA $CERT_BASE/rootCA.pem -CAkey $CERT_BASE/rootCA.key -CAcreateserial -out $CERT_BASE/client.crt -sha256 \ No newline at end of file diff --git a/src/cisco_gnmi/__init__.py b/src/cisco_gnmi/__init__.py index a4153b7..1795817 100644 --- a/src/cisco_gnmi/__init__.py +++ b/src/cisco_gnmi/__init__.py @@ -27,6 +27,7 @@ from .client import Client from .xr import XRClient from .nx import NXClient +from .xe import XEClient from .builder import ClientBuilder -__version__ = "1.0.1" +__version__ = "1.0.2" diff --git a/src/cisco_gnmi/builder.py b/src/cisco_gnmi/builder.py index e9bcc59..c1fcffe 100644 --- a/src/cisco_gnmi/builder.py +++ b/src/cisco_gnmi/builder.py @@ -26,7 +26,7 @@ import logging import grpc -from . import Client, XRClient, NXClient +from . import Client, XRClient, NXClient, XEClient from .auth import CiscoAuthPlugin from .util import gen_target_netloc, get_cert_from_target, get_cn_from_cert @@ -74,7 +74,12 @@ class ClientBuilder(object): >>> print(capabilities) """ - os_class_map = {None: Client, "IOS XR": XRClient, "NX-OS": NXClient} + os_class_map = { + None: Client, + "IOS XR": XRClient, + "NX-OS": NXClient, + "IOS XE": XEClient, + } def __init__(self, target): """Initializes the builder, most initialization is done via set_* methods. @@ -114,8 +119,9 @@ def set_os(self, name=None): name : str "IOS XR" maps to the XRClient class. "NX-OS" maps to the NXClient class. + "IOS XE" maps to the XEClient class. None maps to the base Client class which simply wraps the gNMI stub. - ["IOS XR", "NX-OS", None] + ["IOS XR", "NX-OS", "IOS XE", None] Returns ------- @@ -268,7 +274,7 @@ def construct(self): Returns ------- - Client or XRClient + Client or NXClient or XEClient or XRClient """ channel = None channel_ssl_creds = None diff --git a/src/cisco_gnmi/xe.py b/src/cisco_gnmi/xe.py new file mode 100644 index 0000000..55c3a18 --- /dev/null +++ b/src/cisco_gnmi/xe.py @@ -0,0 +1,323 @@ +"""Copyright 2019 Cisco Systems +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +The contents of this file are licensed under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with the +License. You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +License for the specific language governing permissions and limitations under +the License. +""" + +"""Wrapper for IOS XE to simplify usage of gNMI implementation.""" + +import json +import logging + +from six import string_types +from .client import Client, proto, util + + +class XEClient(Client): + """IOS XE-specific wrapper for gNMI functionality. + Assumes IOS XE 16.12+ + + Returns direct responses from base Client methods. + + Methods + ------- + delete_xpaths(...) + Convenience wrapper for set() which constructs Paths from XPaths for deletion. + get_xpaths(...) + Convenience wrapper for get() which helps construct get requests for specified xpaths. + set_json(...) + Convenience wrapper for set() which assumes model-based JSON payloads. + subscribe_xpaths(...) + Convenience wrapper for subscribe() which helps construct subscriptions for specified xpaths. + + Examples + -------- + >>> from cisco_gnmi import ClientBuilder + >>> client = ClientBuilder('127.0.0.1:9339').set_os( + ... 'IOS XE' + ... ).set_secure_from_file( + ... 'rootCA.pem', + ... 'client.key', + ... 'client.crt' + ... ).set_ssl_target_override().set_call_authentication( + ... 'admin', + ... 'its_a_secret' + ... ).construct() + >>> capabilities = client.capabilities() + >>> print(capabilities) + ... + >>> get_response = client.get_xpaths('/interfaces/interface') + >>> print(get_response) + ... + >>> subscribe_response = client.subscribe_xpaths('/interfaces/interface') + >>> for message in subscribe_response: print(message) + ... + >>> config = '{"Cisco-IOS-XE-native:native": {"hostname": "gnmi_test"}}' + >>> set_response = client.set_json(config) + >>> print(set_response) + ... + >>> delete_response = client.delete_xpaths('/Cisco-IOS-XE-native:native/hostname') + """ + + def delete_xpaths(self, xpaths, prefix=None): + """A convenience wrapper for set() which constructs Paths from supplied xpaths + to be passed to set() as the delete parameter. + + Parameters + ---------- + xpaths : iterable of str + XPaths to specify to be deleted. + If prefix is specified these strings are assumed to be the suffixes. + prefix : str + The XPath prefix to apply to all XPaths for deletion. + + Returns + ------- + set() + """ + if isinstance(xpaths, string_types): + xpaths = [xpaths] + paths = [] + for xpath in xpaths: + if prefix: + if prefix.endswith("/") and xpath.startswith("/"): + xpath = "{prefix}{xpath}".format( + prefix=prefix[:-1], xpath=xpath[1:] + ) + elif prefix.endswith("/") or xpath.startswith("/"): + xpath = "{prefix}{xpath}".format(prefix=prefix, xpath=xpath) + else: + xpath = "{prefix}/{xpath}".format(prefix=prefix, xpath=xpath) + paths.append(self.parse_xpath_to_gnmi_path(xpath)) + return self.set(deletes=paths) + + def set_json(self, update_json_configs=None, replace_json_configs=None, ietf=True): + """A convenience wrapper for set() which assumes JSON payloads and constructs desired messages. + All parameters are optional, but at least one must be present. + + This method expects JSON in the same format as what you might send via the native gRPC interface + with a fully modeled configuration which is then parsed to meet the gNMI implementation. + + Parameters + ---------- + update_json_configs : iterable of JSON configurations, optional + JSON configs to apply as updates. + replace_json_configs : iterable of JSON configurations, optional + JSON configs to apply as replacements. + ietf : bool, optional + Use JSON_IETF vs JSON. + + Returns + ------- + set() + """ + if not any([update_json_configs, replace_json_configs]): + raise Exception("Must supply at least one set of configurations to method!") + + def check_configs(name, configs): + if isinstance(name, string_types): + logging.debug("Handling %s as JSON string.", name) + try: + configs = json.loads(configs) + except: + raise Exception("{name} is invalid JSON!".format(name=name)) + configs = [configs] + elif isinstance(name, dict): + logging.debug("Handling %s as already serialized JSON object.", name) + configs = [configs] + elif not isinstance(configs, (list, set)): + raise Exception( + "{name} must be an iterable of configs!".format(name=name) + ) + return configs + + def create_updates(name, configs): + if not configs: + return None + configs = check_configs(name, configs) + updates = [] + for config in configs: + if not isinstance(config, dict): + raise Exception("config must be a JSON object!") + if len(config.keys()) > 1: + raise Exception("config should only target one YANG module!") + top_element = next(iter(config.keys())) + update = proto.gnmi_pb2.Update() + update.path.CopyFrom(self.parse_xpath_to_gnmi_path(top_element)) + config = config.pop(top_element) + if ietf: + update.val.json_ietf_val = json.dumps(config).encode("utf-8") + else: + update.val.json_val = json.dumps(config).encode("utf-8") + updates.append(update) + return updates + + updates = create_updates("update_json_configs", update_json_configs) + replaces = create_updates("replace_json_configs", replace_json_configs) + return self.set(updates=updates, replaces=replaces) + + def get_xpaths(self, xpaths, data_type="ALL", encoding="JSON_IETF"): + """A convenience wrapper for get() which forms proto.gnmi_pb2.Path from supplied xpaths. + + Parameters + ---------- + xpaths : iterable of str or str + An iterable of XPath strings to request data of + If simply a str, wraps as a list for convenience + data_type : proto.gnmi_pb2.GetRequest.DataType, optional + A direct value or key from the GetRequest.DataType enum + [ALL, CONFIG, STATE, OPERATIONAL] + encoding : proto.gnmi_pb2.GetRequest.Encoding, optional + A direct value or key from the Encoding enum + [JSON, JSON_IETF] + + Returns + ------- + get() + """ + supported_encodings = ["JSON", "JSON_IETF"] + encoding = util.validate_proto_enum( + "encoding", + encoding, + "Encoding", + proto.gnmi_pb2.Encoding, + supported_encodings, + ) + gnmi_path = None + if isinstance(xpaths, (list, set)): + gnmi_path = map(self.parse_xpath_to_gnmi_path, set(xpaths)) + elif isinstance(xpaths, string_types): + gnmi_path = [self.parse_xpath_to_gnmi_path(xpaths)] + else: + raise Exception( + "xpaths must be a single xpath string or iterable of xpath strings!" + ) + return self.get(gnmi_path, data_type=data_type, encoding=encoding) + + def subscribe_xpaths( + self, + xpath_subscriptions, + encoding="JSON_IETF", + sample_interval=Client._NS_IN_S * 10, + heartbeat_interval=None, + ): + """A convenience wrapper of subscribe() which aids in building of SubscriptionRequest + with request as subscribe SubscriptionList. This method accepts an iterable of simply xpath strings, + dictionaries with Subscription attributes for more granularity, or already built Subscription + objects and builds the SubscriptionList. Fields not supplied will be defaulted with the default arguments + to the method. + + Generates a single SubscribeRequest. + + Parameters + ---------- + xpath_subscriptions : str or iterable of str, dict, Subscription + An iterable which is parsed to form the Subscriptions in the SubscriptionList to be passed + to SubscriptionRequest. Strings are parsed as XPaths and defaulted with the default arguments, + dictionaries are treated as dicts of args to pass to the Subscribe init, and Subscription is + treated as simply a pre-made Subscription. + encoding : proto.gnmi_pb2.Encoding, optional + A member of the proto.gnmi_pb2.Encoding enum specifying desired encoding of returned data + [JSON, JSON_IETF] + sample_interval : int, optional + Default nanoseconds for sample to occur. + Defaults to 10 seconds. + heartbeat_interval : int, optional + Specifies the maximum allowable silent period in nanoseconds when + suppress_redundant is in use. The target should send a value at least once + in the period specified. + + Returns + ------- + subscribe() + """ + supported_request_modes = ["STREAM"] + request_mode = "STREAM" + supported_sub_modes = ["SAMPLE"] + sub_mode = "SAMPLE" + supported_encodings = ["JSON", "JSON_IETF"] + subscription_list = proto.gnmi_pb2.SubscriptionList() + subscription_list.mode = util.validate_proto_enum( + "mode", + request_mode, + "SubscriptionList.Mode", + proto.gnmi_pb2.SubscriptionList.Mode, + supported_request_modes, + ) + subscription_list.encoding = util.validate_proto_enum( + "encoding", + encoding, + "Encoding", + proto.gnmi_pb2.Encoding, + supported_encodings, + ) + if isinstance(xpath_subscriptions, string_types): + xpath_subscriptions = [xpath_subscriptions] + for xpath_subscription in xpath_subscriptions: + subscription = None + if isinstance(xpath_subscription, string_types): + subscription = proto.gnmi_pb2.Subscription() + subscription.path.CopyFrom( + self.parse_xpath_to_gnmi_path(xpath_subscription) + ) + subscription.mode = util.validate_proto_enum( + "sub_mode", + sub_mode, + "SubscriptionMode", + proto.gnmi_pb2.SubscriptionMode, + supported_sub_modes, + ) + subscription.sample_interval = sample_interval + elif isinstance(xpath_subscription, dict): + path = self.parse_xpath_to_gnmi_path(xpath_subscription["path"]) + arg_dict = { + "path": path, + "mode": sub_mode, + "sample_interval": sample_interval, + } + arg_dict.update(xpath_subscription) + if "mode" in arg_dict: + arg_dict["mode"] = util.validate_proto_enum( + "sub_mode", + arg_dict["mode"], + "SubscriptionMode", + proto.gnmi_pb2.SubscriptionMode, + supported_sub_modes, + ) + subscription = proto.gnmi_pb2.Subscription(**arg_dict) + elif isinstance(xpath_subscription, proto.gnmi_pb2.Subscription): + subscription = xpath_subscription + else: + raise Exception("xpath in list must be xpath or dict/Path!") + subscription_list.subscription.append(subscription) + return self.subscribe([subscription_list]) + + def parse_xpath_to_gnmi_path(self, xpath, origin=None): + """Naively tries to intelligently (non-sequitur!) origin + Otherwise assume rfc7951 + legacy is not considered + """ + if origin is None: + # naive but effective + if ":" not in xpath: + origin = "openconfig" + else: + origin = "rfc7951" + return super(XEClient, self).parse_xpath_to_gnmi_path(xpath, origin)