diff options
author | FUJITA Tomonori <fujita.tomonori@lab.ntt.co.jp> | 2013-03-03 12:05:21 +0900 |
---|---|---|
committer | FUJITA Tomonori <fujita.tomonori@lab.ntt.co.jp> | 2013-03-05 07:37:44 +0900 |
commit | 8649e9e15356dc2bd6faa9c7e8a41eb89921280c (patch) | |
tree | db18de798d83a87737768b8a767b9f3bdbdbc304 | |
parent | d1a87e87c13568bd30394237e2ee21b21a843b8a (diff) |
contrib: import ncclient library (NETCONF clients)
NETCONF clients
https://github.com/leopoul/ncclient/
Signed-off-by: FUJITA Tomonori <fujita.tomonori@lab.ntt.co.jp>
21 files changed, 1973 insertions, 1 deletions
diff --git a/ryu/contrib/ncclient/__init__.py b/ryu/contrib/ncclient/__init__.py new file mode 100644 index 00000000..3d806d69 --- /dev/null +++ b/ryu/contrib/ncclient/__init__.py @@ -0,0 +1,22 @@ +# Copyright 2009 Shikhar Bhushan +# +# 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. + +import sys + +if sys.version_info < (2, 6): + raise RuntimeError('You need Python 2.6+ for this module.') + +class NCClientError(Exception): + "Base type for all NCClient errors" + pass diff --git a/ryu/contrib/ncclient/capabilities.py b/ryu/contrib/ncclient/capabilities.py new file mode 100644 index 00000000..9a7f2476 --- /dev/null +++ b/ryu/contrib/ncclient/capabilities.py @@ -0,0 +1,68 @@ +# Copyright 2009 Shikhar Bhushan +# +# 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. + +def _abbreviate(uri): + if uri.startswith("urn:ietf:params") and ":netconf:" in uri: + splitted = uri.split(":") + if ":capability:" in uri: + if uri.startswith("urn:ietf:params:xml:ns:netconf"): + name, version = splitted[7], splitted[8] + else: + name, version = splitted[5], splitted[6] + return [ ":" + name, ":" + name + ":" + version ] + elif ":base:" in uri: + if uri.startswith("urn:ietf:params:xml:ns:netconf"): + return [ ":base", ":base" + ":" + splitted[7] ] + else: + return [ ":base", ":base" + ":" + splitted[5] ] + return [] + +def schemes(url_uri): + "Given a URI that has a *scheme* query string (i.e. `:url` capability URI), will return a list of supported schemes." + return url_uri.partition("?scheme=")[2].split(",") + +class Capabilities: + + "Represents the set of capabilities available to a NETCONF client or server. It is initialized with a list of capability URI's." + + def __init__(self, capabilities): + self._dict = {} + for uri in capabilities: + self._dict[uri] = _abbreviate(uri) + + def __contains__(self, key): + if key in self._dict: + return True + for abbrs in self._dict.values(): + if key in abbrs: + return True + return False + + def __len__(self): + return len(self._dict) + + def __iter__(self): + return self._dict.iterkeys() + + def __repr__(self): + return repr(self._dict.keys()) + + def add(self, uri): + "Add a capability." + self._dict[uri] = _abbreviate(uri) + + def remove(self, uri): + "Remove a capability." + if key in self._dict: + del self._dict[key]
\ No newline at end of file diff --git a/ryu/contrib/ncclient/debug.py b/ryu/contrib/ncclient/debug.py new file mode 100644 index 00000000..65429cfe --- /dev/null +++ b/ryu/contrib/ncclient/debug.py @@ -0,0 +1,24 @@ +# Copyright 2009 Shikhar Bhushan +# +# 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. + +from ncclient.transport import SessionListener + +class PrintListener(SessionListener): + + def callback(self, root, raw): + print('\n# RECEIVED MESSAGE with root=[tag=%r, attrs=%r] #\n%r\n' % + (root[0], root[1], raw)) + + def errback(self, err): + print('\n# RECEIVED ERROR #\n%r\n' % err) diff --git a/ryu/contrib/ncclient/manager.py b/ryu/contrib/ncclient/manager.py new file mode 100644 index 00000000..eb61727b --- /dev/null +++ b/ryu/contrib/ncclient/manager.py @@ -0,0 +1,177 @@ +# Copyright 2009 Shikhar Bhushan +# Copyright 2011 Leonidas Poulopoulos +# +# 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. + +"""This module is a thin layer of abstraction around the library. It exposes all core functionality.""" + +import capabilities +import operations +import transport + +import logging + +logger = logging.getLogger('ncclient.manager') + +CAPABILITIES = [ + "urn:ietf:params:netconf:base:1.0", + "urn:ietf:params:netconf:capability:writable-running:1.0", + "urn:ietf:params:netconf:capability:candidate:1.0", + "urn:ietf:params:netconf:capability:confirmed-commit:1.0", + "urn:ietf:params:netconf:capability:rollback-on-error:1.0", + "urn:ietf:params:netconf:capability:startup:1.0", + "urn:ietf:params:netconf:capability:url:1.0?scheme=http,ftp,file,https,sftp", + "urn:ietf:params:netconf:capability:validate:1.0", + "urn:ietf:params:netconf:capability:xpath:1.0", + "urn:liberouter:params:netconf:capability:power-control:1.0" + "urn:ietf:params:netconf:capability:interleave:1.0" +] +"""A list of URI's representing the client's capabilities. This is used during the initial capability exchange. Modify this if you need to announce some capability not already included.""" + +OPERATIONS = { + "get": operations.Get, + "get_config": operations.GetConfig, + "dispatch": operations.Dispatch, + "edit_config": operations.EditConfig, + "copy_config": operations.CopyConfig, + "validate": operations.Validate, + "commit": operations.Commit, + "discard_changes": operations.DiscardChanges, + "delete_config": operations.DeleteConfig, + "lock": operations.Lock, + "unlock": operations.Unlock, + "close_session": operations.CloseSession, + "kill_session": operations.KillSession, + "poweroff_machine": operations.PoweroffMachine, + "reboot_machine": operations.RebootMachine +} +"""Dictionary of method names and corresponding :class:`~ncclient.operations.RPC` subclasses. It is used to lookup operations, e.g. `get_config` is mapped to :class:`~ncclient.operations.GetConfig`. It is thus possible to add additional operations to the :class:`Manager` API.""" + +def connect_ssh(*args, **kwds): + """Initialize a :class:`Manager` over the SSH transport. For documentation of arguments see :meth:`ncclient.transport.SSHSession.connect`. + + The underlying :class:`ncclient.transport.SSHSession` is created with :data:`CAPABILITIES`. It is first instructed to :meth:`~ncclient.transport.SSHSession.load_known_hosts` and then all the provided arguments are passed directly to its implementation of :meth:`~ncclient.transport.SSHSession.connect`. + """ + session = transport.SSHSession(capabilities.Capabilities(CAPABILITIES)) + session.load_known_hosts() + session.connect(*args, **kwds) + return Manager(session) + +connect = connect_ssh +"Same as :func:`connect_ssh`, since SSH is the default (and currently, the only) transport." + +class OpExecutor(type): + + def __new__(cls, name, bases, attrs): + def make_wrapper(op_cls): + def wrapper(self, *args, **kwds): + return self.execute(op_cls, *args, **kwds) + wrapper.func_doc = op_cls.request.func_doc + return wrapper + for op_name, op_cls in OPERATIONS.iteritems(): + attrs[op_name] = make_wrapper(op_cls) + return super(OpExecutor, cls).__new__(cls, name, bases, attrs) + +class Manager(object): + + """For details on the expected behavior of the operations and their parameters refer to :rfc:`4741`. + + Manager instances are also context managers so you can use it like this:: + + with manager.connect("host") as m: + # do your stuff + + ... or like this:: + + m = manager.connect("host") + try: + # do your stuff + finally: + m.close_session() + """ + + __metaclass__ = OpExecutor + + def __init__(self, session, timeout=30): + self._session = session + self._async_mode = False + self._timeout = timeout + self._raise_mode = operations.RaiseMode.ALL + + def __enter__(self): + return self + + def __exit__(self, *args): + self.close_session() + return False + + def __set_timeout(self, timeout): + self._timeout = timeout + + def __set_async_mode(self, mode): + self._async_mode = mode + + def __set_raise_mode(self, mode): + assert(mode in (operations.RaiseMode.NONE, operations.RaiseMode.ERRORS, operations.RaiseMode.ALL)) + self._raise_mode = mode + + def execute(self, cls, *args, **kwds): + return cls(self._session, + async=self._async_mode, + timeout=self._timeout, + raise_mode=self._raise_mode).request(*args, **kwds) + + def locked(self, target): + """Returns a context manager for a lock on a datastore, where *target* is the name of the configuration datastore to lock, e.g.:: + + with m.locked("running"): + # do your stuff + + ... instead of:: + + m.lock("running") + try: + # do your stuff + finally: + m.unlock("running") + """ + return operations.LockContext(self._session, target) + + @property + def client_capabilities(self): + ":class:`~ncclient.capabilities.Capabilities` object representing the client's capabilities." + return self._session._client_capabilities + + @property + def server_capabilities(self): + ":class:`~ncclient.capabilities.Capabilities` object representing the server's capabilities." + return self._session._server_capabilities + + @property + def session_id(self): + "`session-id` assigned by the NETCONF server." + return self._session.id + + @property + def connected(self): + "Whether currently connected to the NETCONF server." + return self._session.connected + + async_mode = property(fget=lambda self: self._async_mode, fset=__set_async_mode) + "Specify whether operations are executed asynchronously (`True`) or synchronously (`False`) (the default)." + + timeout = property(fget=lambda self: self._timeout, fset=__set_timeout) + "Specify the timeout for synchronous RPC requests." + + raise_mode = property(fget=lambda self: self._raise_mode, fset=__set_raise_mode) + "Specify which errors are raised as :exc:`~ncclient.operations.RPCError` exceptions. Valid values are the constants defined in :class:`~ncclient.operations.RaiseMode`. The default value is :attr:`~ncclient.operations.RaiseMode.ALL`." diff --git a/ryu/contrib/ncclient/operations/__init__.py b/ryu/contrib/ncclient/operations/__init__.py new file mode 100644 index 00000000..1f56b2f0 --- /dev/null +++ b/ryu/contrib/ncclient/operations/__init__.py @@ -0,0 +1,51 @@ +# Copyright 2009 Shikhar Bhushan +# +# 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. + +from errors import OperationError, TimeoutExpiredError, MissingCapabilityError +from rpc import RPC, RPCReply, RPCError, RaiseMode + +# rfc4741 ops +from retrieve import Get, GetConfig, GetReply, Dispatch +from edit import EditConfig, CopyConfig, DeleteConfig, Validate, Commit, DiscardChanges +from session import CloseSession, KillSession +from lock import Lock, Unlock, LockContext +# others... +from flowmon import PoweroffMachine, RebootMachine + +__all__ = [ + 'RPC', + 'RPCReply', + 'RPCError', + 'RaiseMode', + 'Get', + 'GetConfig', + 'Dispatch', + 'GetReply', + 'EditConfig', + 'CopyConfig', + 'Validate', + 'Commit', + 'DiscardChanges', + 'DeleteConfig', + 'Lock', + 'Unlock', + 'PoweroffMachine', + 'RebootMachine', + 'LockContext', + 'CloseSession', + 'KillSession', + 'OperationError', + 'TimeoutExpiredError', + 'MissingCapabilityError' +] diff --git a/ryu/contrib/ncclient/operations/edit.py b/ryu/contrib/ncclient/operations/edit.py new file mode 100644 index 00000000..a2dbd942 --- /dev/null +++ b/ryu/contrib/ncclient/operations/edit.py @@ -0,0 +1,143 @@ +# Copyright 2009 Shikhar Bhushan +# +# 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. + +from ncclient.xml_ import * + +from rpc import RPC + +import util + +import logging + +logger = logging.getLogger("ncclient.operations.edit") + +"Operations related to changing device configuration" + +class EditConfig(RPC): + "`edit-config` RPC" + + def request(self, target, config, default_operation=None, test_option=None, error_option=None): + """Loads all or part of the specified *config* to the *target* configuration datastore. + + *target* is the name of the configuration datastore being edited + + *config* is the configuration, which must be rooted in the `config` element. It can be specified either as a string or an :class:`~xml.etree.ElementTree.Element`. + + *default_operation* if specified must be one of { `"merge"`, `"replace"`, or `"none"` } + + *test_option* if specified must be one of { `"test_then_set"`, `"set"` } + + *error_option* if specified must be one of { `"stop-on-error"`, `"continue-on-error"`, `"rollback-on-error"` } + + The `"rollback-on-error"` *error_option* depends on the `:rollback-on-error` capability. + """ + node = new_ele("edit-config") + node.append(util.datastore_or_url("target", target, self._assert)) + if error_option is not None: + if error_option == "rollback-on-error": + self._assert(":rollback-on-error") + sub_ele(node, "error-option").text = error_option + if test_option is not None: + self._assert(':validate') + sub_ele(node, "test-option").text = test_option + if default_operation is not None: + # TODO: check if it is a valid default-operation + sub_ele(node, "default-operation").text = default_operation + node.append(validated_element(config, ("config", qualify("config")))) + return self._request(node) + + +class DeleteConfig(RPC): + "`delete-config` RPC" + + def request(self, target): + """Delete a configuration datastore. + + *target* specifies the name or URL of configuration datastore to delete + + :seealso: :ref:`srctarget_params`""" + node = new_ele("delete-config") + node.append(util.datastore_or_url("target", target, self._assert)) + return self._request(node) + + +class CopyConfig(RPC): + "`copy-config` RPC" + + def request(self, source, target): + """Create or replace an entire configuration datastore with the contents of another complete + configuration datastore. + + *source* is the name of the configuration datastore to use as the source of the copy operation or `config` element containing the configuration subtree to copy + + *target* is the name of the configuration datastore to use as the destination of the copy operation + + :seealso: :ref:`srctarget_params`""" + node = new_ele("copy-config") + node.append(util.datastore_or_url("target", target, self._assert)) + node.append(util.datastore_or_url("source", source, self._assert)) + return self._request(node) + + +class Validate(RPC): + "`validate` RPC. Depends on the `:validate` capability." + + DEPENDS = [':validate'] + + def request(self, source): + """Validate the contents of the specified configuration. + + *source* is the name of the configuration datastore being validated or `config` element containing the configuration subtree to be validated + + :seealso: :ref:`srctarget_params`""" + node = new_ele("validate") + try: + src = validated_element(source, ("config", qualify("config"))) + except Exception as e: + logger.debug(e) + src = util.datastore_or_url("source", source, self._assert) + (node if src.tag == "source" else sub_ele(node, "source")).append(src) + return self._request(node) + + +class Commit(RPC): + "`commit` RPC. Depends on the `:candidate` capability, and the `:confirmed-commit`." + + DEPENDS = [':candidate'] + + def request(self, confirmed=False, timeout=None): + """Commit the candidate configuration as the device's new current configuration. Depends on the `:candidate` capability. + + A confirmed commit (i.e. if *confirmed* is `True`) is reverted if there is no followup commit within the *timeout* interval. If no timeout is specified the confirm timeout defaults to 600 seconds (10 minutes). A confirming commit may have the *confirmed* parameter but this is not required. Depends on the `:confirmed-commit` capability. + + *confirmed* whether this is a confirmed commit + + *timeout* specifies the confirm timeout in seconds""" + node = new_ele("commit") + if confirmed: + self._assert(":confirmed-commit") + sub_ele(node, "confirmed") + if timeout is not None: + sub_ele(node, "confirm-timeout").text = timeout + return self._request(node) + + +class DiscardChanges(RPC): + "`discard-changes` RPC. Depends on the `:candidate` capability." + + DEPENDS = [":candidate"] + + def request(self): + """Revert the candidate configuration to the currently running configuration. Any uncommitted changes are discarded.""" + return self._request(new_ele("discard-changes"))
\ No newline at end of file diff --git a/ryu/contrib/ncclient/operations/errors.py b/ryu/contrib/ncclient/operations/errors.py new file mode 100644 index 00000000..623abeda --- /dev/null +++ b/ryu/contrib/ncclient/operations/errors.py @@ -0,0 +1,24 @@ +# Copyright 2009 Shikhar Bhushan +# +# 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. + +from ncclient import NCClientError + +class OperationError(NCClientError): + pass + +class TimeoutExpiredError(NCClientError): + pass + +class MissingCapabilityError(NCClientError): + pass diff --git a/ryu/contrib/ncclient/operations/flowmon.py b/ryu/contrib/ncclient/operations/flowmon.py new file mode 100644 index 00000000..76759865 --- /dev/null +++ b/ryu/contrib/ncclient/operations/flowmon.py @@ -0,0 +1,39 @@ +# Copyright 2h009 Shikhar Bhushan +# +# 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. + +'Power-control operations' + +from ncclient.xml_ import * + +from rpc import RPC + +PC_URN = "urn:liberouter:params:xml:ns:netconf:power-control:1.0" + +class PoweroffMachine(RPC): + + "*poweroff-machine* RPC (flowmon)" + + DEPENDS = ["urn:liberouter:param:netconf:capability:power-control:1.0"] + + def request(self): + return self._request(new_ele(qualify("poweroff-machine", PC_URN))) + +class RebootMachine(RPC): + + "*reboot-machine* RPC (flowmon)" + + DEPENDS = ["urn:liberouter:params:netconf:capability:power-control:1.0"] + + def request(self): + return self._request(new_ele(qualify("reboot-machine", PC_URN))) diff --git a/ryu/contrib/ncclient/operations/lock.py b/ryu/contrib/ncclient/operations/lock.py new file mode 100644 index 00000000..13f5bdb0 --- /dev/null +++ b/ryu/contrib/ncclient/operations/lock.py @@ -0,0 +1,70 @@ +# Copyright 2h009 Shikhar Bhushan +# +# 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. + +"Locking-related NETCONF operations" + +from ncclient.xml_ import * + +from rpc import RaiseMode, RPC + +# TODO: parse session-id from a lock-denied error, and raise a tailored exception? + +class Lock(RPC): + + "`lock` RPC" + + def request(self, target): + """Allows the client to lock the configuration system of a device. + + *target* is the name of the configuration datastore to lock + """ + node = new_ele("lock") + sub_ele(sub_ele(node, "target"), target) + return self._request(node) + + +class Unlock(RPC): + + "`unlock` RPC" + + def request(self, target): + """Release a configuration lock, previously obtained with the lock operation. + + *target* is the name of the configuration datastore to unlock + """ + node = new_ele("unlock") + sub_ele(sub_ele(node, "target"), target) + return self._request(node) + + +class LockContext: + + """A context manager for the :class:`Lock` / :class:`Unlock` pair of RPC's. + + Any `rpc-error` will be raised as an exception. + + Initialise with (:class:`Session <ncclient.transport.Session>`) instance and lock target. + """ + + def __init__(self, session, target): + self.session = session + self.target = target + + def __enter__(self): + Lock(self.session, raise_mode=RaiseMode.ERRORS).request(self.target) + return self + + def __exit__(self, *args): + Unlock(self.session, raise_mode=RaiseMode.ERRORS).request(self.target) + return False diff --git a/ryu/contrib/ncclient/operations/retrieve.py b/ryu/contrib/ncclient/operations/retrieve.py new file mode 100644 index 00000000..e7fe8dce --- /dev/null +++ b/ryu/contrib/ncclient/operations/retrieve.py @@ -0,0 +1,127 @@ +# Copyright 2009 Shikhar Bhushan +# +# 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. + +from rpc import RPC, RPCReply + +from ncclient.xml_ import * + +import util + +class GetReply(RPCReply): + + """Adds attributes for the *data* element to `RPCReply`.""" + + def _parsing_hook(self, root): + self._data = None + if not self._errors: + self._data = root.find(qualify("data")) + + @property + def data_ele(self): + "*data* element as an :class:`~xml.etree.ElementTree.Element`" + if not self._parsed: + self.parse() + return self._data + + @property + def data_xml(self): + "*data* element as an XML string" + if not self._parsed: + self.parse() + return to_xml(self._data) + + data = data_ele + "Same as :attr:`data_ele`" + + +class Get(RPC): + + "The *get* RPC." + + REPLY_CLS = GetReply + "See :class:`GetReply`." + + def request(self, filter=None): + """Retrieve running configuration and device state information. + + *filter* specifies the portion of the configuration to retrieve (by default entire configuration is retrieved) + + :seealso: :ref:`filter_params` + """ + node = new_ele("get") + if filter is not None: + node.append(util.build_filter(filter)) + return self._request(node) + + +class GetConfig(RPC): + + "The *get-config* RPC." + + REPLY_CLS = GetReply + "See :class:`GetReply`." + + def request(self, source, filter=None): + """Retrieve all or part of a specified configuration. + + *source* name of the configuration datastore being queried + + *filter* specifies the portion of the configuration to retrieve (by default entire configuration is retrieved) + + :seealso: :ref:`filter_params`""" + node = new_ele("get-config") + node.append(util.datastore_or_url("source", source, self._assert)) + if filter is not None: + node.append(util.build_filter(filter)) + return self._request(node) + +class Dispatch(RPC): + + "Generic retrieving wrapper" + + REPLY_CLS = GetReply + "See :class:`GetReply`." + + def request(self, rpc_command, source=None, filter=None): + """ + *rpc_command* specifies rpc command to be dispatched either in plain text or in xml element format (depending on command) + + *source* name of the configuration datastore being queried + + *filter* specifies the portion of the configuration to retrieve (by default entire configuration is retrieved) + + :seealso: :ref:`filter_params` + + Examples of usage:: + + dispatch('clear-arp-table') + + or dispatch element like :: + + xsd_fetch = new_ele('get-xnm-information') + sub_ele(xsd_fetch, 'type').text="xml-schema" + sub_ele(xsd_fetch, 'namespace').text="junos-configuration" + dispatch(xsd_fetch) + """ + + if ET.iselement(rpc_command): + node = rpc_command + else: + node = new_ele(rpc_command) + if source is not None: + node.append(util.datastore_or_url("source", source, self._assert)) + if filter is not None: + node.append(util.build_filter(filter)) + return self._request(node) + diff --git a/ryu/contrib/ncclient/operations/rpc.py b/ryu/contrib/ncclient/operations/rpc.py new file mode 100644 index 00000000..a476e184 --- /dev/null +++ b/ryu/contrib/ncclient/operations/rpc.py @@ -0,0 +1,373 @@ +# Copyright 2009 Shikhar Bhushan +# +# 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. + +from threading import Event, Lock +from uuid import uuid1 + +from ncclient.xml_ import * +from ncclient.transport import SessionListener + +from errors import OperationError, TimeoutExpiredError, MissingCapabilityError + +import logging +logger = logging.getLogger("ncclient.operations.rpc") + + +class RPCError(OperationError): + + "Represents an `rpc-error`. It is a type of :exc:`OperationError` and can be raised as such." + + tag_to_attr = { + qualify("error-type"): "_type", + qualify("error-tag"): "_tag", + qualify("error-severity"): "_severity", + qualify("error-info"): "_info", + qualify("error-path"): "_path", + qualify("error-message"): "_message" + } + + def __init__(self, raw): + self._raw = raw + for attr in RPCError.tag_to_attr.values(): + setattr(self, attr, None) + for subele in raw: + attr = RPCError.tag_to_attr.get(subele.tag, None) + if attr is not None: + setattr(self, attr, subele.text if attr != "_info" else to_xml(subele) ) + if self.message is not None: + OperationError.__init__(self, self.message) + else: + OperationError.__init__(self, self.to_dict()) + + def to_dict(self): + return dict([ (attr[1:], getattr(self, attr)) for attr in RPCError.tag_to_attr.values() ]) + + @property + def xml(self): + "The `rpc-error` element as returned in XML." + return self._raw + + @property + def type(self): + "The contents of the `error-type` element." + return self._type + + @property + def tag(self): + "The contents of the `error-tag` element." + return self._tag + + @property + def severity(self): + "The contents of the `error-severity` element." + return self._severity + + @property + def path(self): + "The contents of the `error-path` element if present or `None`." + return self._path + + @property + def message(self): + "The contents of the `error-message` element if present or `None`." + return self._message + + @property + def info(self): + "XML string or `None`; representing the `error-info` element." + return self._info + + +class RPCReply: + + """Represents an *rpc-reply*. Only concerns itself with whether the operation was successful. + + .. note:: + If the reply has not yet been parsed there is an implicit, one-time parsing overhead to + accessing some of the attributes defined by this class. + """ + + ERROR_CLS = RPCError + "Subclasses can specify a different error class, but it should be a subclass of `RPCError`." + + def __init__(self, raw): + self._raw = raw + self._parsed = False + self._root = None + self._errors = [] + + def __repr__(self): + return self._raw + + def parse(self): + "Parses the *rpc-reply*." + if self._parsed: return + root = self._root = to_ele(self._raw) # The <rpc-reply> element + # Per RFC 4741 an <ok/> tag is sent when there are no errors or warnings + ok = root.find(qualify("ok")) + if ok is None: + # Create RPCError objects from <rpc-error> elements + error = root.find(qualify("rpc-error")) + if error is not None: + for err in root.getiterator(error.tag): + # Process a particular <rpc-error> + self._errors.append(self.ERROR_CLS(err)) + self._parsing_hook(root) + self._parsed = True + + def _parsing_hook(self, root): + "No-op by default. Gets passed the *root* element for the reply." + pass + + @property + def xml(self): + "*rpc-reply* element as returned." + return self._raw + + @property + def ok(self): + "Boolean value indicating if there were no errors." + return not self.errors # empty list => false + + @property + def error(self): + "Returns the first :class:`RPCError` and `None` if there were no errors." + self.parse() + if self._errors: + return self._errors[0] + else: + return None + + @property + def errors(self): + "List of `RPCError` objects. Will be empty if there were no *rpc-error* elements in reply." + self.parse() + return self._errors + + +class RPCReplyListener(SessionListener): # internal use + + creation_lock = Lock() + + # one instance per session -- maybe there is a better way?? + def __new__(cls, session): + with RPCReplyListener.creation_lock: + instance = session.get_listener_instance(cls) + if instance is None: + instance = object.__new__(cls) + instance._lock = Lock() + instance._id2rpc = {} + #instance._pipelined = session.can_pipeline + session.add_listener(instance) + return instance + + def register(self, id, rpc): + with self._lock: + self._id2rpc[id] = rpc + + def callback(self, root, raw): + tag, attrs = root + if tag != qualify("rpc-reply"): + return + for key in attrs: # in the <rpc-reply> attributes + if key == "message-id": # if we found msgid attr + id = attrs[key] # get the msgid + with self._lock: + try: + rpc = self._id2rpc[id] # the corresponding rpc + logger.debug("Delivering to %r" % rpc) + rpc.deliver_reply(raw) + except KeyError: + raise OperationError("Unknown 'message-id': %s", id) + # no catching other exceptions, fail loudly if must + else: + # if no error delivering, can del the reference to the RPC + del self._id2rpc[id] + break + else: + raise OperationError("Could not find 'message-id' attribute in <rpc-reply>") + + def errback(self, err): + try: + for rpc in self._id2rpc.values(): + rpc.deliver_error(err) + finally: + self._id2rpc.clear() + + +class RaiseMode(object): + + NONE = 0 + "Don't attempt to raise any type of `rpc-error` as :exc:`RPCError`." + + ERRORS = 1 + "Raise only when the `error-type` indicates it is an honest-to-god error." + + ALL = 2 + "Don't look at the `error-type`, always raise." + + +class RPC(object): + + """Base class for all operations, directly corresponding to *rpc* requests. Handles making the request, and taking delivery of the reply.""" + + DEPENDS = [] + """Subclasses can specify their dependencies on capabilities as a list of URI's or abbreviated names, e.g. ':writable-running'. These are verified at the time of instantiation. If the capability is not available, :exc:`MissingCapabilityError` is raised.""" + + REPLY_CLS = RPCReply + "By default :class:`RPCReply`. Subclasses can specify a :class:`RPCReply` subclass." + + def __init__(self, session, async=False, timeout=30, raise_mode=RaiseMode.NONE): + """ + *session* is the :class:`~ncclient.transport.Session` instance + + *async* specifies whether the request is to be made asynchronously, see :attr:`is_async` + + *timeout* is the timeout for a synchronous request, see :attr:`timeout` + + *raise_mode* specifies the exception raising mode, see :attr:`raise_mode` + """ + self._session = session + try: + for cap in self.DEPENDS: + self._assert(cap) + except AttributeError: + pass + self._async = async + self._timeout = timeout + self._raise_mode = raise_mode + self._id = uuid1().urn # Keeps things simple instead of having a class attr with running ID that has to be locked + self._listener = RPCReplyListener(session) + self._listener.register(self._id, self) + self._reply = None + self._error = None + self._event = Event() + + def _wrap(self, subele): + # internal use + ele = new_ele("rpc", {"message-id": self._id}) + ele.append(subele) + return to_xml(ele) + + def _request(self, op): + """Implementations of :meth:`request` call this method to send the request and process the reply. + + In synchronous mode, blocks until the reply is received and returns :class:`RPCReply`. Depending on the :attr:`raise_mode` a `rpc-error` element in the reply may lead to an :exc:`RPCError` exception. + + In asynchronous mode, returns immediately, returning `self`. The :attr:`event` attribute will be set when the reply has been received (see :attr:`reply`) or an error occured (see :attr:`error`). + + *op* is the operation to be requested as an :class:`~xml.etree.ElementTree.Element` + """ + logger.info('Requesting %r' % self.__class__.__name__) + req = self._wrap(op) + self._session.send(req) + if self._async: + logger.debug('Async request, returning %r', self) + return self + else: + logger.debug('Sync request, will wait for timeout=%r' % self._timeout) + self._event.wait(self._timeout) + if self._event.isSet(): + if self._error: + # Error that prevented reply delivery + raise self._error + self._reply.parse() + if self._reply.error is not None: + # <rpc-error>'s [ RPCError ] + if self._raise_mode == RaiseMode.ALL: + raise self._reply.error + elif (self._raise_mode == RaiseMode.ERRORS and self._reply.error.type == "error"): + raise self._reply.error + return self._reply + else: + raise TimeoutExpiredError + + def request(self): + """Subclasses must implement this method. Typically only the request needs to be built as an + :class:`~xml.etree.ElementTree.Element` and everything else can be handed off to + :meth:`_request`.""" + pass + + def _assert(self, capability): + """Subclasses can use this method to verify that a capability is available with the NETCONF + server, before making a request that requires it. A :exc:`MissingCapabilityError` will be + raised if the capability is not available.""" + if capability not in self._session.server_capabilities: + raise MissingCapabilityError('Server does not support [%s]' % capability) + + def deliver_reply(self, raw): + # internal use + self._reply = self.REPLY_CLS(raw) + self._event.set() + + def deliver_error(self, err): + # internal use + self._error = err + self._event.set() + + @property + def reply(self): + ":class:`RPCReply` element if reply has been received or `None`" + return self._reply + + @property + def error(self): + """:exc:`Exception` type if an error occured or `None`. + + .. note:: + This represents an error which prevented a reply from being received. An *rpc-error* + does not fall in that category -- see `RPCReply` for that. + """ + return self._error + + @property + def id(self): + "The *message-id* for this RPC." + return self._id + + @property + def session(self): + "The `~ncclient.transport.Session` object associated with this RPC." + return self._session + + @property + def event(self): + """:class:`~threading.Event` that is set when reply has been received or when an error preventing + delivery of the reply occurs. + """ + return self._event + + def __set_async(self, async=True): + self._async = async + if async and not session.can_pipeline: + raise UserWarning('Asynchronous mode not supported for this device/session') + + def __set_raise_mode(self, mode): + assert(choice in ("all", "errors", "none")) + self._raise_mode = mode + + def __set_timeout(self, timeout): + self._timeout = timeout + + raise_mode = property(fget=lambda self: self._raise_mode, fset=__set_raise_mode) + """Depending on this exception raising mode, an `rpc-error` in the reply may be raised as an :exc:`RPCError` exception. Valid values are the constants defined in :class:`RaiseMode`. """ + + is_async = property(fget=lambda self: self._async, fset=__set_async) + """Specifies whether this RPC will be / was requested asynchronously. By default RPC's are synchronous.""" + + timeout = property(fget=lambda self: self._timeout, fset=__set_timeout) + """Timeout in seconds for synchronous waiting defining how long the RPC request will block on a reply before raising :exc:`TimeoutExpiredError`. + + Irrelevant for asynchronous usage. + """ diff --git a/ryu/contrib/ncclient/operations/session.py b/ryu/contrib/ncclient/operations/session.py new file mode 100644 index 00000000..0afa3072 --- /dev/null +++ b/ryu/contrib/ncclient/operations/session.py @@ -0,0 +1,44 @@ +# Copyright 2009 Shikhar Bhushan +# +# 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. + +"Session-related NETCONF operations" + +from ncclient.xml_ import * + +from rpc import RPC + +class CloseSession(RPC): + + "`close-session` RPC. The connection to NETCONF server is also closed." + + def request(self): + "Request graceful termination of the NETCONF session, and also close the transport." + try: + return self._request(new_ele("close-session")) + finally: + self.session.close() + + +class KillSession(RPC): + + "`kill-session` RPC." + + def request(self, session_id): + """Force the termination of a NETCONF session (not the current one!) + + *session_id* is the session identifier of the NETCONF session to be terminated as a string + """ + node = new_ele("kill-session") + sub_ele(node, "session-id").text = session_id + return self._request(node) diff --git a/ryu/contrib/ncclient/operations/subscribe.py b/ryu/contrib/ncclient/operations/subscribe.py new file mode 100644 index 00000000..44635d34 --- /dev/null +++ b/ryu/contrib/ncclient/operations/subscribe.py @@ -0,0 +1,24 @@ +# Copyright 2009 Shikhar Bhushan +# +# 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. + +# TODO + +class Notification: + pass + +class CreateSubscription: + pass + +class NotificationListener: + pass diff --git a/ryu/contrib/ncclient/operations/util.py b/ryu/contrib/ncclient/operations/util.py new file mode 100644 index 00000000..e11ae598 --- /dev/null +++ b/ryu/contrib/ncclient/operations/util.py @@ -0,0 +1,65 @@ +# Copyright 2009 Shikhar Bhushan +# +# 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. + +'Boilerplate ugliness' + +from ncclient.xml_ import * + +from errors import OperationError, MissingCapabilityError + +def one_of(*args): + "Verifies that only one of the arguments is not None" + for i, arg in enumerate(args): + if arg is not None: + for argh in args[i+1:]: + if argh is not None: + raise OperationError("Too many parameters") + else: + return + raise OperationError("Insufficient parameters") + +def datastore_or_url(wha, loc, capcheck=None): + node = new_ele(wha) + if "://" in loc: # e.g. http://, file://, ftp:// + if capcheck is not None: + capcheck(":url") # url schema check at some point! + sub_ele(node, "url").text = loc + else: + #if loc == 'candidate': + # capcheck(':candidate') + #elif loc == 'startup': + # capcheck(':startup') + #elif loc == 'running' and wha == 'target': + # capcheck(':writable-running') + sub_ele(node, loc) + return node + +def build_filter(spec, capcheck=None): + type = None + if isinstance(spec, tuple): + type, criteria = spec + rep = new_ele("filter", type=type) + if type == "xpath": + rep.attrib["select"] = criteria + elif type == "subtree": + rep.append(to_ele(criteria)) + else: + raise OperationError("Invalid filter type") + else: + rep = validated_element(spec, ("filter", qualify("filter")), + attrs=("type",)) + # TODO set type var here, check if select attr present in case of xpath.. + if type == "xpath" and capcheck is not None: + capcheck(":xpath") + return rep diff --git a/ryu/contrib/ncclient/transport/__init__.py b/ryu/contrib/ncclient/transport/__init__.py new file mode 100644 index 00000000..51c4a155 --- /dev/null +++ b/ryu/contrib/ncclient/transport/__init__.py @@ -0,0 +1,30 @@ +# Copyright 2009 Shikhar Bhushan +# +# 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. + +"Transport layer" + +from session import Session, SessionListener +from ssh import SSHSession +from errors import * + +__all__ = [ + 'Session', + 'SessionListener', + 'SSHSession', + 'TransportError', + 'AuthenticationError', + 'SessionCloseError', + 'SSHError', + 'SSHUnknownHostError' +]
\ No newline at end of file diff --git a/ryu/contrib/ncclient/transport/errors.py b/ryu/contrib/ncclient/transport/errors.py new file mode 100644 index 00000000..ec95c682 --- /dev/null +++ b/ryu/contrib/ncclient/transport/errors.py @@ -0,0 +1,41 @@ +# Copyright 2009 Shikhar Bhushan +# +# 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. + +from ncclient import NCClientError + +class TransportError(NCClientError): + pass + +class AuthenticationError(TransportError): + pass + +class SessionCloseError(TransportError): + + def __init__(self, in_buf, out_buf=None): + msg = 'Unexpected session close' + if in_buf: + msg += '\nIN_BUFFER: `%s`' % in_buf + if out_buf: + msg += ' OUT_BUFFER: `%s`' % out_buf + SSHError.__init__(self, msg) + +class SSHError(TransportError): + pass + +class SSHUnknownHostError(SSHError): + + def __init__(self, host, fingerprint): + SSHError.__init__(self, 'Unknown host key [%s] for [%s]' % (fingerprint, host)) + self.host = host + self.fingerprint = fingerprint diff --git a/ryu/contrib/ncclient/transport/session.py b/ryu/contrib/ncclient/transport/session.py new file mode 100644 index 00000000..46f7a4c6 --- /dev/null +++ b/ryu/contrib/ncclient/transport/session.py @@ -0,0 +1,229 @@ +# Copyright 2009 Shikhar Bhushan +# +# 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. + +from Queue import Queue +from threading import Thread, Lock, Event + +from ncclient.xml_ import * +from ncclient.capabilities import Capabilities + +from errors import TransportError + +import logging +logger = logging.getLogger('ncclient.transport.session') + +class Session(Thread): + + "Base class for use by transport protocol implementations." + + def __init__(self, capabilities): + Thread.__init__(self) + self.setDaemon(True) + self._listeners = set() + self._lock = Lock() + self.setName('session') + self._q = Queue() + self._client_capabilities = capabilities + self._server_capabilities = None # yet + self._id = None # session-id + self._connected = False # to be set/cleared by subclass implementation + logger.debug('%r created: client_capabilities=%r' % + (self, self._client_capabilities)) + + def _dispatch_message(self, raw): + try: + root = parse_root(raw) + except Exception as e: + logger.error('error parsing dispatch message: %s' % e) + return + with self._lock: + listeners = list(self._listeners) + for l in listeners: + logger.debug('dispatching message to %r: %s' % (l, raw)) + l.callback(root, raw) # no try-except; fail loudly if you must! + + def _dispatch_error(self, err): + with self._lock: + listeners = list(self._listeners) + for l in listeners: + logger.debug('dispatching error to %r' % l) + try: # here we can be more considerate with catching exceptions + l.errback(err) + except Exception as e: + logger.warning('error dispatching to %r: %r' % (l, e)) + + def _post_connect(self): + "Greeting stuff" + init_event = Event() + error = [None] # so that err_cb can bind error[0]. just how it is. + # callbacks + def ok_cb(id, capabilities): + self._id = id + self._server_capabilities = capabilities + init_event.set() + def err_cb(err): + error[0] = err + init_event.set() + listener = HelloHandler(ok_cb, err_cb) + self.add_listener(listener) + self.send(HelloHandler.build(self._client_capabilities)) + logger.debug('starting main loop') + self.start() + # we expect server's hello message + init_event.wait() + # received hello message or an error happened + self.remove_listener(listener) + if error[0]: + raise error[0] + #if ':base:1.0' not in self.server_capabilities: + # raise MissingCapabilityError(':base:1.0') + logger.info('initialized: session-id=%s | server_capabilities=%s' % + (self._id, self._server_capabilities)) + + def add_listener(self, listener): + """Register a listener that will be notified of incoming messages and + errors. + + :type listener: :class:`SessionListener` + """ + logger.debug('installing listener %r' % listener) + if not isinstance(listener, SessionListener): + raise SessionError("Listener must be a SessionListener type") + with self._lock: + self._listeners.add(listener) + + def remove_listener(self, listener): + """Unregister some listener; ignore if the listener was never + registered. + + :type listener: :class:`SessionListener` + """ + logger.debug('discarding listener %r' % listener) + with self._lock: + self._listeners.discard(listener) + + def get_listener_instance(self, cls): + """If a listener of the specified type is registered, returns the + instance. + + :type cls: :class:`SessionListener` + """ + with self._lock: + for listener in self._listeners: + if isinstance(listener, cls): + return listener + + def connect(self, *args, **kwds): # subclass implements + raise NotImplementedError + + def run(self): # subclass implements + raise NotImplementedError + + def send(self, message): + """Send the supplied *message* (xml string) to NETCONF server.""" + if not self.connected: + raise TransportError('Not connected to NETCONF server') + logger.debug('queueing %s' % message) + self._q.put(message) + + ### Properties + + @property + def connected(self): + "Connection status of the session." + return self._connected + + @property + def client_capabilities(self): + "Client's :class:`Capabilities`" + return self._client_capabilities + + @property + def server_capabilities(self): + "Server's :class:`Capabilities`" + return self._server_capabilities + + @property + def id(self): + """A string representing the `session-id`. If the session has not been initialized it will be `None`""" + return self._id + + +class SessionListener(object): + + """Base class for :class:`Session` listeners, which are notified when a new + NETCONF message is received or an error occurs. + + .. note:: + Avoid time-intensive tasks in a callback's context. + """ + + def callback(self, root, raw): + """Called when a new XML document is received. The *root* argument allows the callback to determine whether it wants to further process the document. + + Here, *root* is a tuple of *(tag, attributes)* where *tag* is the qualified name of the root element and *attributes* is a dictionary of its attributes (also qualified names). + + *raw* will contain the XML document as a string. + """ + raise NotImplementedError + + def errback(self, ex): + """Called when an error occurs. + + :type ex: :exc:`Exception` + """ + raise NotImplementedError + + +class HelloHandler(SessionListener): + + def __init__(self, init_cb, error_cb): + self._init_cb = init_cb + self._error_cb = error_cb + + def callback(self, root, raw): + tag, attrs = root + if (tag == qualify("hello")) or (tag == "hello"): + try: + id, capabilities = HelloHandler.parse(raw) + except Exception as e: + self._error_cb(e) + else: + self._init_cb(id, capabilities) + + def errback(self, err): + self._error_cb(err) + + @staticmethod + def build(capabilities): + "Given a list of capability URI's returns <hello> message XML string" + hello = new_ele("hello") + caps = sub_ele(hello, "capabilities") + def fun(uri): sub_ele(caps, "capability").text = uri + map(fun, capabilities) + return to_xml(hello) + + @staticmethod + def parse(raw): + "Returns tuple of (session-id (str), capabilities (Capabilities)" + sid, capabilities = 0, [] + root = to_ele(raw) + for child in root.getchildren(): + if child.tag == qualify("session-id") or child.tag == "session-id": + sid = child.text + elif child.tag == qualify("capabilities") or child.tag == "capabilities" : + for cap in child.getchildren(): + if cap.tag == qualify("capability") or cap.tag == "capability": + capabilities.append(cap.text) + return sid, Capabilities(capabilities) diff --git a/ryu/contrib/ncclient/transport/ssh.py b/ryu/contrib/ncclient/transport/ssh.py new file mode 100644 index 00000000..14082da9 --- /dev/null +++ b/ryu/contrib/ncclient/transport/ssh.py @@ -0,0 +1,312 @@ +# Copyright 2009 Shikhar Bhushan +# +# 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. + +import os +import socket +import getpass +from binascii import hexlify +from cStringIO import StringIO +from select import select + +import paramiko + +from errors import AuthenticationError, SessionCloseError, SSHError, SSHUnknownHostError +from session import Session + +import logging +logger = logging.getLogger("ncclient.transport.ssh") + +BUF_SIZE = 4096 +MSG_DELIM = "]]>]]>" +TICK = 0.1 + +def default_unknown_host_cb(host, fingerprint): + """An unknown host callback returns `True` if it finds the key acceptable, and `False` if not. + + This default callback always returns `False`, which would lead to :meth:`connect` raising a :exc:`SSHUnknownHost` exception. + + Supply another valid callback if you need to verify the host key programatically. + + *host* is the hostname that needs to be verified + + *fingerprint* is a hex string representing the host key fingerprint, colon-delimited e.g. `"4b:69:6c:72:6f:79:20:77:61:73:20:68:65:72:65:21"` + """ + return False + +def _colonify(fp): + finga = fp[:2] + for idx in range(2, len(fp), 2): + finga += ":" + fp[idx:idx+2] + return finga + +class SSHSession(Session): + + "Implements a :rfc:`4742` NETCONF session over SSH." + + def __init__(self, capabilities): + Session.__init__(self, capabilities) + self._host_keys = paramiko.HostKeys() + self._transport = None + self._connected = False + self._channel = None + self._buffer = StringIO() # for incoming data + # parsing-related, see _parse() + self._parsing_state = 0 + self._parsing_pos = 0 + + def _parse(self): + "Messages ae delimited by MSG_DELIM. The buffer could have grown by a maximum of BUF_SIZE bytes everytime this method is called. Retains state across method calls and if a byte has been read it will not be considered again." + delim = MSG_DELIM + n = len(delim) - 1 + expect = self._parsing_state + buf = self._buffer + buf.seek(self._parsing_pos) + while True: + x = buf.read(1) + if not x: # done reading + break + elif x == delim[expect]: # what we expected + expect += 1 # expect the next delim char + else: + expect = 0 + continue + # loop till last delim char expected, break if other char encountered + for i in range(expect, n): + x = buf.read(1) + if not x: # done reading + break + if x == delim[expect]: # what we expected + expect += 1 # expect the next delim char + else: + expect = 0 # reset + break + else: # if we didn't break out of the loop, full delim was parsed + msg_till = buf.tell() - n + buf.seek(0) + logger.debug('parsed new message') + self._dispatch_message(buf.read(msg_till).strip()) + buf.seek(n+1, os.SEEK_CUR) + rest = buf.read() + buf = StringIO() + buf.write(rest) + buf.seek(0) + expect = 0 + self._buffer = buf + self._parsing_state = expect + self._parsing_pos = self._buffer.tell() + + def load_known_hosts(self, filename=None): + """Load host keys from an openssh :file:`known_hosts`-style file. Can be called multiple times. + + If *filename* is not specified, looks in the default locations i.e. :file:`~/.ssh/known_hosts` and :file:`~/ssh/known_hosts` for Windows. + """ + if filename is None: + filename = os.path.expanduser('~/.ssh/known_hosts') + try: + self._host_keys.load(filename) + except IOError: + # for windows + filename = os.path.expanduser('~/ssh/known_hosts') + try: + self._host_keys.load(filename) + except IOError: + pass + else: + self._host_keys.load(filename) + + def close(self): + if self._transport.is_active(): + self._transport.close() + self._connected = False + + # REMEMBER to update transport.rst if sig. changes, since it is hardcoded there + def connect(self, host, port=830, timeout=None, unknown_host_cb=default_unknown_host_cb, + username=None, password=None, key_filename=None, allow_agent=True, look_for_keys=True): + """Connect via SSH and initialize the NETCONF session. First attempts the publickey authentication method and then password authentication. + + To disable attempting publickey authentication altogether, call with *allow_agent* and *look_for_keys* as `False`. + + *host* is the hostname or IP address to connect to + + *port* is by default 830, but some devices use the default SSH port of 22 so this may need to be specified + + *timeout* is an optional timeout for socket connect + + *unknown_host_cb* is called when the server host key is not recognized. It takes two arguments, the hostname and the fingerprint (see the signature of :func:`default_unknown_host_cb`) + + *username* is the username to use for SSH authentication + + *password* is the password used if using password authentication, or the passphrase to use for unlocking keys that require it + + *key_filename* is a filename where a the private key to be used can be found + + *allow_agent* enables querying SSH agent (if found) for keys + + *look_for_keys* enables looking in the usual locations for ssh keys (e.g. :file:`~/.ssh/id_*`) + """ + if username is None: + username = getpass.getuser() + + sock = None + for res in socket.getaddrinfo(host, port, socket.AF_UNSPEC, socket.SOCK_STREAM): + af, socktype, proto, canonname, sa = res + try: + sock = socket.socket(af, socktype, proto) + sock.settimeout(timeout) + except socket.error: + continue + try: + sock.connect(sa) + except socket.error: + sock.close() + continue + break + else: + raise SSHError("Could not open socket to %s:%s" % (host, port)) + + t = self._transport = paramiko.Transport(sock) + t.set_log_channel(logger.name) + + try: + t.start_client() + except paramiko.SSHException: + raise SSHError('Negotiation failed') + + # host key verification + server_key = t.get_remote_server_key() + known_host = self._host_keys.check(host, server_key) + + fingerprint = _colonify(hexlify(server_key.get_fingerprint())) + + if not known_host and not unknown_host_cb(host, fingerprint): + raise SSHUnknownHostError(host, fingerprint) + + if key_filename is None: + key_filenames = [] + elif isinstance(key_filename, basestring): + key_filenames = [ key_filename ] + else: + key_filenames = key_filename + + self._auth(username, password, key_filenames, allow_agent, look_for_keys) + + self._connected = True # there was no error authenticating + + c = self._channel = self._transport.open_session() + c.set_name("netconf") + c.invoke_subsystem("netconf") + + self._post_connect() + + # on the lines of paramiko.SSHClient._auth() + def _auth(self, username, password, key_filenames, allow_agent, + look_for_keys): + saved_exception = None + + for key_filename in key_filenames: + for cls in (paramiko.RSAKey, paramiko.DSSKey): + try: + key = cls.from_private_key_file(key_filename, password) + logger.debug("Trying key %s from %s" % + (hexlify(key.get_fingerprint()), key_filename)) + self._transport.auth_publickey(username, key) + return + except Exception as e: + saved_exception = e + logger.debug(e) + + if allow_agent: + for key in paramiko.Agent().get_keys(): + try: + logger.debug("Trying SSH agent key %s" % + hexlify(key.get_fingerprint())) + self._transport.auth_publickey(username, key) + return + except Exception as e: + saved_exception = e + logger.debug(e) + + keyfiles = [] + if look_for_keys: + rsa_key = os.path.expanduser("~/.ssh/id_rsa") + dsa_key = os.path.expanduser("~/.ssh/id_dsa") + if os.path.isfile(rsa_key): + keyfiles.append((paramiko.RSAKey, rsa_key)) + if os.path.isfile(dsa_key): + keyfiles.append((paramiko.DSSKey, dsa_key)) + # look in ~/ssh/ for windows users: + rsa_key = os.path.expanduser("~/ssh/id_rsa") + dsa_key = os.path.expanduser("~/ssh/id_dsa") + if os.path.isfile(rsa_key): + keyfiles.append((paramiko.RSAKey, rsa_key)) + if os.path.isfile(dsa_key): + keyfiles.append((paramiko.DSSKey, dsa_key)) + + for cls, filename in keyfiles: + try: + key = cls.from_private_key_file(filename, password) + logger.debug("Trying discovered key %s in %s" % + (hexlify(key.get_fingerprint()), filename)) + self._transport.auth_publickey(username, key) + return + except Exception as e: + saved_exception = e + logger.debug(e) + + if password is not None: + try: + self._transport.auth_password(username, password) + return + except Exception as e: + saved_exception = e + logger.debug(e) + + if saved_exception is not None: + # need pep-3134 to do this right + raise AuthenticationError(repr(saved_exception)) + + raise AuthenticationError("No authentication methods available") + + def run(self): + chan = self._channel + q = self._q + try: + while True: + # select on a paramiko ssh channel object does not ever return it in the writable list, so channels don't exactly emulate the socket api + r, w, e = select([chan], [], [], TICK) + # will wakeup evey TICK seconds to check if something to send, more if something to read (due to select returning chan in readable list) + if r: + data = chan.recv(BUF_SIZE) + if data: + self._buffer.write(data) + self._parse() + else: + raise SessionCloseError(self._buffer.getvalue()) + if not q.empty() and chan.send_ready(): + logger.debug("Sending message") + data = q.get() + MSG_DELIM + while data: + n = chan.send(data) + if n <= 0: + raise SessionCloseError(self._buffer.getvalue(), data) + data = data[n:] + except Exception as e: + logger.debug("Broke out of main loop, error=%r", e) + self.close() + self._dispatch_error(e) + + @property + def transport(self): + "Underlying `paramiko.Transport <http://www.lag.net/paramiko/docs/paramiko.Transport-class.html>`_ object. This makes it possible to call methods like :meth:`~paramiko.Transport.set_keepalive` on it." + return self._transport diff --git a/ryu/contrib/ncclient/xml_.py b/ryu/contrib/ncclient/xml_.py new file mode 100644 index 00000000..9e94ef0a --- /dev/null +++ b/ryu/contrib/ncclient/xml_.py @@ -0,0 +1,108 @@ +# Copyright 2009 Shikhar Bhushan +# Copyright 2011 Leonidas Poulopoulos +# +# 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. + +"Methods for creating, parsing, and dealing with XML and ElementTree objects." + +from cStringIO import StringIO +from xml.etree import cElementTree as ET + +# In case issues come up with XML generation/parsing +# make sure you have the ElementTree v1.2.7+ lib + +from ncclient import NCClientError + +class XMLError(NCClientError): pass + +### Namespace-related + +#: Base NETCONF namespace +BASE_NS_1_0 = "urn:ietf:params:xml:ns:netconf:base:1.0" +#: Namespace for Tail-f core data model +TAILF_AAA_1_1 = "http://tail-f.com/ns/aaa/1.1" +#: Namespace for Tail-f execd data model +TAILF_EXECD_1_1 = "http://tail-f.com/ns/execd/1.1" +#: Namespace for Cisco data model +CISCO_CPI_1_0 = "http://www.cisco.com/cpi_10/schema" +#: Namespace for Flowmon data model +FLOWMON_1_0 = "http://www.liberouter.org/ns/netopeer/flowmon/1.0" +#: Namespace for Juniper 9.6R4. Tested with Junos 9.6R4+ +JUNIPER_1_1 = "http://xml.juniper.net/xnm/1.1/xnm" +# +try: + register_namespace = ET.register_namespace +except AttributeError: + def register_namespace(prefix, uri): + from xml.etree import ElementTree + # cElementTree uses ElementTree's _namespace_map, so that's ok + ElementTree._namespace_map[uri] = prefix +register_namespace.func_doc = "ElementTree's namespace map determines the prefixes for namespace URI's when serializing to XML. This method allows modifying this map to specify a prefix for a namespace URI." + +for (ns, pre) in { + BASE_NS_1_0: 'nc', + TAILF_AAA_1_1: 'aaa', + TAILF_EXECD_1_1: 'execd', + CISCO_CPI_1_0: 'cpi', + FLOWMON_1_0: 'fm', + JUNIPER_1_1: 'junos', +}.items(): + register_namespace(pre, ns) + +qualify = lambda tag, ns=BASE_NS_1_0: tag if ns is None else "{%s}%s" % (ns, tag) +"""Qualify a *tag* name with a *namespace*, in :mod:`~xml.etree.ElementTree` fashion i.e. *{namespace}tagname*.""" + +def to_xml(ele, encoding="UTF-8"): + "Convert and return the XML for an *ele* (:class:`~xml.etree.ElementTree.Element`) with specified *encoding*." + xml = ET.tostring(ele, encoding) + return xml if xml.startswith('<?xml') else '<?xml version="1.0" encoding="%s"?>%s' % (encoding, xml) + +def to_ele(x): + "Convert and return the :class:`~xml.etree.ElementTree.Element` for the XML document *x*. If *x* is already an :class:`~xml.etree.ElementTree.Element` simply returns that." + return x if ET.iselement(x) else ET.fromstring(x) + +def parse_root(raw): + "Efficiently parses the root element of a *raw* XML document, returning a tuple of its qualified name and attribute dictionary." + fp = StringIO(raw) + for event, element in ET.iterparse(fp, events=('start',)): + return (element.tag, element.attrib) + +def validated_element(x, tags=None, attrs=None): + """Checks if the root element of an XML document or Element meets the supplied criteria. + + *tags* if specified is either a single allowable tag name or sequence of allowable alternatives + + *attrs* if specified is a sequence of required attributes, each of which may be a sequence of several allowable alternatives + + Raises :exc:`XMLError` if the requirements are not met. + """ + ele = to_ele(x) + if tags: + if isinstance(tags, basestring): + tags = [tags] + if ele.tag not in tags: + raise XMLError("Element [%s] does not meet requirement" % ele.tag) + if attrs: + for req in attrs: + if isinstance(req, basestring): req = [req] + for alt in req: + if alt in ele.attrib: + break + else: + raise XMLError("Element [%s] does not have required attributes" % ele.tag) + return ele + +new_ele = lambda tag, attrs={}, **extra: ET.Element(qualify(tag), attrs, **extra) + +sub_ele = lambda parent, tag, attrs={}, **extra: ET.SubElement(parent, qualify(tag), attrs, **extra) + @@ -6,7 +6,7 @@ source-dir = doc/source [bdist_rpm] Release = 1 Group = Applications/Accessories -Requires = python-gevent >= 0.13, python-routes, python-webob +Requires = python-gevent >= 0.13, python-routes, python-webob, python-paramiko doc_files = LICENSE MANIFEST.in README.rst diff --git a/tools/pip-requires b/tools/pip-requires index 7be30721..2a9b4ce4 100644 --- a/tools/pip-requires +++ b/tools/pip-requires @@ -1,3 +1,4 @@ gevent>=0.13 routes webob>=1.0.8 +paramiko |