contrib: import ncclient library (NETCONF clients)

NETCONF clients

https://github.com/leopoul/ncclient/

Signed-off-by: FUJITA Tomonori <fujita.tomonori@lab.ntt.co.jp>
This commit is contained in:
FUJITA Tomonori 2013-03-03 12:05:21 +09:00
parent d1a87e87c1
commit 8649e9e153
21 changed files with 1973 additions and 1 deletions

View File

@ -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

View File

@ -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]

View File

@ -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)

View File

@ -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`."

View File

@ -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'
]

View File

@ -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"))

View File

@ -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

View File

@ -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)))

View File

@ -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

View File

@ -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)

View File

@ -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.
"""

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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'
]

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -1,3 +1,4 @@
gevent>=0.13
routes
webob>=1.0.8
paramiko