diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..48bd7a89 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,36 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: bug +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Your script +2. What you're connecting to (vendor, platform, version) +3. Anything else relevant + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Stack Trace** +Copy of your stack trace here, please format it properly using triple back ticks (top left key on US keyboards!) + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**OS (please complete the following information):** + - OS: [e.g. Ubuntu, MacOS, etc. - Note this is *not* tested on Windows and likely will not be supported] + - nssh version + - ssh2python version + - paramiko version + - python version + +**Additional context** +Add any other context about the problem here. diff --git a/.github/workflows/commit.yaml b/.github/workflows/commit.yaml new file mode 100644 index 00000000..09d3a1f2 --- /dev/null +++ b/.github/workflows/commit.yaml @@ -0,0 +1,26 @@ +name: linting and unit tests + +on: [push] + +jobs: + build: + runs-on: ${{ matrix.os }} + strategy: + max-parallel: 6 + matrix: + os: [ubuntu-latest, macos-latest] + python-version: [3.6, 3.7, 3.8] + steps: + - uses: actions/checkout@v1 + - name: set up python ${{ matrix.python-version }} + uses: actions/setup-python@v1 + with: + python-version: ${{ matrix.python-version }} + - name: setup test env + run: | + python -m pip install --upgrade pip + python -m pip install setuptools + python -m pip install tox + - name: run tox + run: python -m tox --skip-missing-interpreters=true + diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml new file mode 100644 index 00000000..1914ac5c --- /dev/null +++ b/.github/workflows/publish.yaml @@ -0,0 +1,49 @@ +name: re-test and publish to pypi + +on: + release: + types: [created] + +jobs: + build: + runs-on: ${{ matrix.os }} + strategy: + max-parallel: 6 + matrix: + os: [ubuntu-latest, macos-latest] + python-version: [3.6, 3.7, 3.8] + steps: + - uses: actions/checkout@v1 + - name: set up python ${{ matrix.python-version }} + uses: actions/setup-python@v1 + with: + python-version: ${{ matrix.python-version }} + - name: setup test env + run: | + python -m pip install --upgrade pip + python -m pip install setuptools + python -m pip install tox + - name: run tox + run: python -m tox --skip-missing-interpreters=true + + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v1 + - name: set up python 3.8 + uses: actions/setup-python@v1 + with: + python-version: 3.8 + - name: setup publish env + run: | + python -m pip install --upgrade pip + python -m pip install setuptools + python -m pip install wheel + python -m pip install twine + - name: build and publish + env: + TWINE_USERNAME: ${{ secrets.PYPI_USER }} + TWINE_PASSWORD: ${{ secrets.PYPI_PASS }} + run: | + python setup.py sdist bdist_wheel + python -m twine upload dist/* diff --git a/.github/workflows/weekly.yaml b/.github/workflows/weekly.yaml new file mode 100644 index 00000000..7a49acb2 --- /dev/null +++ b/.github/workflows/weekly.yaml @@ -0,0 +1,28 @@ +name: weekly linting and unit tests + +on: + schedule: + # weekly at 0700 PST/1400 UTC on Sunday + - cron: '0 14 * * 0' + +jobs: + build: + runs-on: ${{ matrix.os }} + strategy: + max-parallel: 6 + matrix: + os: [ubuntu-latest, macos-latest] + python-version: [3.6, 3.7, 3.8] + steps: + - uses: actions/checkout@v1 + - name: set up python ${{ matrix.python-version }} + uses: actions/setup-python@v1 + with: + python-version: ${{ matrix.python-version }} + - name: setup test env + run: | + python -m pip install --upgrade pip + python -m pip install setuptools + python -m pip install tox + - name: run tox + run: python -m tox --skip-missing-interpreters=true diff --git a/.pylintrc b/.pylintrc index 5606d7e4..711ddbe8 100644 --- a/.pylintrc +++ b/.pylintrc @@ -65,7 +65,7 @@ confidence= # --enable=similarities". If you want to run only the classes checker, but have # no Warning level messages displayed, use"--disable=all --enable=classes # --disable=W" -disable=C0103,C0115,C0330,W0105,W1202,W1203,R0902,R0913,W0511 +disable=C0103,C0115,C0330,W0105,W1202,W1203,R0902,R0913 # C0103 = constant-name (a little too aggressive for some things that aren't "really" constants") # C0115 = class docstrings (init doc strings cover this already) # C0330 = bad-continuation (hanging indent that black doesnt like) @@ -74,7 +74,6 @@ disable=C0103,C0115,C0330,W0105,W1202,W1203,R0902,R0913,W0511 # W1203 = logging-fstring-interpolation (py3.6, using f-strings so dont care) # R0902 = too-many-instance-attributes # R0913 = too-many-arguments -# W0511 -- TODO just for now... [REPORTS] diff --git a/README.md b/README.md index e69de29b..48453c45 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,617 @@ +![](https://github.com/carlmontanari/nssh/workflows/build/badge.svg) +[![PyPI version](https://badge.fury.io/py/nssh.svg)](https://badge.fury.io/py/nssh) +[![Python 3.6](https://img.shields.io/badge/python-3.6-blue.svg)](https://www.python.org/downloads/release/python-360/) +[![Python 3.7](https://img.shields.io/badge/python-3.7-blue.svg)](https://www.python.org/downloads/release/python-370/) +[![Python 3.8](https://img.shields.io/badge/python-3.8-blue.svg)](https://www.python.org/downloads/release/python-380/) +[![Code Style](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/ambv/black) + + +nssh +======= + +nssh is a python library focused on connecting to devices, specifically network devices (routers/switches/firewalls +/etc.) via SSH. nssh's goal is to be as fast and flexible as possible, while providing a well typed, well documented +, simple API. + +nssh is built primarily in two parts: transport and channel. The transport layer is responsible for providing a file +-like interface to the target SSH server. The channel layer is responsible for reading and writing to the provided + file-like interface. + +There are three available "drivers" for the transport layer -- all of which inherit from a base transport class and + provide the same file-like interface to the upstream channel. The transport drivers are: + +- [paramiko]() +- [ssh2-python]() +- OpenSSH/System available SSH + +A good question to ask at this point is probably "why?". Why multiple transport options? Why not just use paramiko + like most folks do? Historically the reason for moving away from paramiko was simply speed. ssh2-python is a wrapper + around the libssh2 C library, and as such is very very fast. In a prior project ([ssh2net]()), of which nssh is the + successor/evolution, ssh2-python was used with great success, however, it is a bit feature-limited, and devlopment + seems to have stalled. + +This led to moving back to paramiko, which of course is a fantastic project with tons and tons of feature support +. Paramiko, however, does not "direct" OpenSSH support, and I don't believe it provides 100% full OpenSSH support + either (ex: ControlPersist). Fully supporting an OpenSSH config file would be an ideal end goal for nssh, something + that may not be possible with Paramiko - ControlPersist in particular is very interesting to me. + +With the goal of supporting all of the OpenSSH configuration options the final transport driver option is simply + native system local SSH (almost certainly this won't work on Windows, but I don't have a Windows box to test on, or + any particular interest in doing so). The implementation of using system SSH is of course a little bit messy + , however nssh takes care of that for you so you don't need to care about it! The payoff of using system SSH is of + course that OpenSSH config files simply "work" -- no passing it to nssh, no selective support, no need to set + username or ports or any of the other config items that may reside in your SSH config file. The "system" + transport driver is still a bit of a work in progress, but in testing has been reliable thus far. + +The final piece of nssh is the actual "driver" -- or the component that binds the transport and channel together and + deals with instantiation of an nssh object. There is a "base" driver object -- `NSSH` -- which provides essentially + a "raw" SSH connection with read and write methods (provided by the channel object), and not much else. More + specific "drivers" can inherit from this class to extend functionality of the driver to make it more friendly for + network devices. + + +# Table of Contents + +- [Documentation](#documentation) +- [Supported Platforms](#supported-platforms) +- [Installation](#installation) +- [Examples Links](#examples-links) +- [Basic Usage](#basic-usage) + - [Native and Platform Drivers Examples](#native-and-platform-drivers-examples) + - [Platform Regex](#platform-regex) + - [Basic Operations -- Sending and Receiving](#basic-operations----sending-and-receiving) + - [Result Objects](#result-objects) + - [Handling Prompts](#handling-prompts) + - [Driver Privilege Levels](#driver-privilege-levels) + - [Sending Configurations](#sending-configurations) + - [TextFSM/NTC-Templates Integration](#textfsmntc-templates-integration) + - [Timeouts](#timeouts) + - [Disabling Paging](#disabling-paging) + - [Login Handlers](#login-handlers) + - [SSH Config Support](#ssh-config-support) +- [FAQ](#faq) +- [Known Issues](#known-issues) +- [Linting and Testing](#linting-and-testing) + + +# Documentation + +Documentation is auto-generated [using pdoc3](https://github.com/pdoc3/pdoc). Documentation is linted (see Linting and + Testing section) via [pydocstyle](https://github.com/PyCQA/pydocstyle/) and [darglint](https://github.com/terrencepreilly/darglint). + +Documentation is hosted via GitHub Pages and can be found [here.](https://carlmontanari.github.io/nssh/docs/nssh/index.html). + You can also view the readme as a web page [here.](https://carlmontanari.github.io/nssh/) + +To regenerate documentation locally, use the following make command: + +``` +make docs +``` + + +# Supported Platforms + +nssh "core" drivers cover basically the [NAPALM](https://github.com/napalm-automation/napalm) platforms -- Cisco IOS-XE, + IOS-XR, NX-OS, Arista EOS, and Juniper JunOS. These drivers provide an interface tailored to network device "screen + -scraping" rather than just a generic SSH connection/channel. + +At the moment there are five "core" drivers representing the most common networking platforms (outlined below) +, however in the future it would be possible for folks to contribute additional "community" drivers. It is unlikely + that any additional "core" platforms would be added at the moment. + +- Cisco IOS-XE (tested on: 16.04.01) +- Cisco NX-OS (tested on: 9.2.4) +- Juniper JunOS (tested on: 17.3R2.10) +- Cisco IOS-XR (tested on: 6.5.3) +- Arista EOS (tested on: 4.22.1F) + +This "driver" pattern is pretty much exactly like the implementation in NAPALM. The driver extends the base class/base + networking driver class with device specific functionality such as privilege escalation/de-escalation, setting + appropriate prompts to search for, and picking out appropriate [ntc templates](https://github.com/napalm-automation/napalm) + for use with TextFSM. + +All of this is focused on network device type SSH cli interfaces, but should work on pretty much any SSH connection + (though there are almost certainly better options for non-network type devices!). This "base" (`NSSH`) connection does + not handle any kind of device-specific operations such as privilege escalation or saving configurations, it is simply + intended to be a bare bones connection that can interact with nearly any device/platform if you are willing to + send/parse inputs/outputs manually. + +The goal for all "core" devices will be to include functional tests that can run against [vrnetlab](https://github.com/plajjan/vrnetlab) + containers to ensure that the "core" devices are as thoroughly tested as is practical. + + +# Installation + +You should be able to pip install it "normally": + +``` +pip install nssh +``` + +To install from this repositories master branch: + +``` +pip install git+https://github.com/carlmontanari/nssh +``` + +To install from source: + +``` +git clone https://github.com/carlmontanari/nssh +cd nssh +python setup.py install +``` + +As for platforms to *run* nssh on -- it has and will be tested on MacOS and Ubuntu regularly and should work on any + POSIX system. + + +# Examples Links + +- [Basic "native" NSSH operations](/examples/basic_usage/nssh_driver.py) +- [Basic "driver" NSSH operations](/examples/basic_usage/iosxe_driver.py) +- [Setting up basic logging](/examples/logging/basic_logging.py) +- [Using SSH Key for authentication](/examples/ssh_keys/ssh_keys.py) +- [Using SSH config file](/) + + +# Basic Usage + +## Native and Platform Drivers Examples + +Example NSSH "native/base" connection: + +```python +from nssh import NSSH + +my_device = {"host": "172.18.0.11", "auth_username": "vrnetlab", "auth_password": "VR-netlab9"} +conn = NSSH(**my_device) +conn.open() +# do stuff! +``` + +Example IOS-XE driver setup. This also shows using context manager which is also supported on "native" mode -- when + using the context manager there is no need to call the "open_shell" method: + +```python +from nssh.driver.core import IOSXEDriver + +my_device = {"host": "172.18.0.11", "auth_username": "vrnetlab", "auth_password": "VR-netlab9"} +with IOSXEDriver(**my_device) as conn: + print(conn) + # do stuff! +``` + +## Platform Regex + +Due to the nature of SSH there is no good way to know when a command has completed execution. Put another way, when + sending any command, data is returned over a socket, that socket doesn't ever tell us when it is "done" sending the + output from the command that was executed. In order to know when the session is "back at the base prompt/starting + point" nssh uses a regular expression pattern to find that base prompt. + +This pattern is contained in the `comms_prompt_pattern` setting, and is perhaps the most important argument to getting + nssh working. + +The "base" (default, but changeable) pattern is: + +`"^[a-z0-9.\-@()/:]{1,20}[#>$]$"` + +*NOTE* all `comms_prompt_pattern` should use the start and end of line anchors as all regex searches in nssh are + multline (this is an important piece to making this all work!). While you don't *need* to use the line anchors its + probably a really good idea! + +The above pattern works on all "core" platforms listed above for at the very least basic usage. Custom prompts or + hostnames could in theory break this, so be careful! + +If you do not wish to match Cisco "config" level prompts you could use a `comms_prompt_pattern` such as: + +`"^[a-z0-9.-@]{1,20}[#>$]$"` + +If you use a platform driver, the base prompt is set in the driver so you don't really need to worry about this! + +The `comms_prompt_pattern` pattern can be changed at any time at or after instantiation of an nssh object. Changing + this *can* break things though, so be careful! + + +## Basic Operations -- Sending and Receiving + +Sending inputs and receiving outputs is done through the base NSSH object or your selected driver object. The inputs + /outputs all are processed (sent/read) via the channel object. If using the base `NSSH` object you must use the + `channel.send_inputs` method -- the `NetworkDriver` and platform specific drivers have a `send_commands` method as + outlined below. The following example shows sending a "show version" command as a string. Also shown: `send_inputs + ` accepts a list/tuple of commands. + +```python +from nssh import NSSH + +my_device = {"host": "172.18.0.11", "auth_username": "vrnetlab", "auth_password": "VR-netlab9"} +with NSSH(**my_device) as conn: + results = conn.channel.send_inputs("show version") + results = conn.channel.send_inputs(("show version", "show run")) +``` + +When using a network "driver", it is more desirable to use the `send_commands` method to send commands (commands that + would be ran at privilege exec in Cisco terms, or similar privilege level for the other platforms). `send_commands` is + just a thin wrapper around `send_inputs`, however it ensures that the device is at the appropriate prompt + (`default_desired_priv` attribute of the specific driver, see [Driver Privilege Levels](#driver-privilege-levels)). + +```python +from nssh.driver.core import IOSXEDriver + +my_device = {"host": "172.18.0.11", "auth_username": "vrnetlab", "auth_password": "VR-netlab9"} +with IOSXEDriver(**my_device) as conn: + results = conn.send_commands("show version") + results = conn.send_commands(("show version", "show run")) +``` + + +## Result Objects + +All read operations result in a `Result` object being created. The `Result` object contains attributes for the command + sent (`channel_input`), start/end/elapsed time, and of course the result of the command sent. + +```python +from nssh.driver.core import IOSXEDriver + +my_device = {"host": "172.18.0.11", "auth_username": "vrnetlab", "auth_password": "VR-netlab9"} +with IOSXEDriver(**my_device) as conn: + results = conn.send_commands("show version") + print(results[0].elapsed_time) + print(results[0].result) +``` + + +## Handling Prompts + +In some cases you may need to run an "interactive" command on your device. The `send_inputs_interact` method can be + used to handle these situations. This method accepts a tuple containing the initial input (command) to send, the + expected prompt after the initial send, the response to that prompt, and the final expected prompt -- basically + telling nssh when it is done with the interactive command. In the below example the expectation is that the + current/base prompt is the final expected prompt, so we can simply call the `get_prompt` method to snag that + directly off the router. + +```python +from nssh.driver.core import IOSXEDriver + +my_device = {"host": "172.18.0.11", "auth_username": "vrnetlab", "auth_password": "VR-netlab9"} +interact = ["clear logging", "Clear logging buffer [confirm]", "\n"] + +with IOSXEDriver(**my_device) as conn: + interactive = conn.channel.send_inputs_interact( + ("clear logging", "Clear logging buffer [confirm]", "\n", conn.get_prompt()) + ) +``` + + +## Driver Privilege Levels + +The "core" drivers understand the basic privilege levels of their respective device types. As mentioned previously +, the drivers will automatically attain the "privilege_exec" (or equivalent) privilege level prior to executing "show +" commands. If you don't want this "auto-magic" you can use the base driver (nssh). The privileges for each device + are outlined in named tuples in the platforms `driver.py` file. + +As an example, the following privilege levels are supported by the IOSXEDriver: + +1. "exec" +2. "privilege_exec" +3. "configuration" +4. "special_configuration" + +Each privilege level has the following attributes: + +- pattern: regex pattern to associate prompt to privilege level with +- name: name of the priv level, i.e. "exec" +- deescalate_priv: name of next lower privilege or None +- deescalate: command to deescalate to next lower privilege or None +- escalate: name of next higher privilege or None +- escalate_auth: command to escalate to next higher privilege or None +- escalate_prompt: False or pattern to expect for escalation -- i.e. "Password:" +- requestable: True/False if the privilege level is requestable +- level: integer value of level i.e. 1 + +If you wish to manually enter a privilege level you can use the `acquire_priv` method, passing in the name of the + privilege level you would like to enter. In general you probably won't need this too often though as the driver + should handle much of this for you. + +```python +from nssh.driver.core import IOSXEDriver + +my_device = {"host": "172.18.0.11", "auth_username": "vrnetlab", "auth_password": "VR-netlab9"} + +with IOSXEDriver(**my_device) as conn: + conn.acquire_priv("configuration") +``` + + +## Sending Configurations + +When using the native mode (`NSSH` object), sending configurations is no different than sending commands and is done via + the `send_inputs` method. You must manually ensure you are in the correct privilege/mode. + +When using any of the core drivers or the base `NetworkDriver`, you can send configurations via the `send_configs` method + which will handle privilege escalation for you. As with the `send_commands` and `send_inputs` methods -- you can + send a single string or a list/tuple of strings. + +```python +from nssh.driver.core import IOSXEDriver + +my_device = {"host": "172.18.0.11", "auth_username": "vrnetlab", "auth_password": "VR-netlab9"} + +with IOSXEDriver(**my_device) as conn: + conn.send_configs(("interface loopback123", "description configured by nssh")) +``` + + +## TextFSM/NTC-Templates Integration + +nssh supports parsing output with TextFSM. This of course requires installing TextFSM and having ntc-templates + somewhere on your system. When using a driver you can pass `textfsm=True` to the `send_commands` method to + automatically try to parse all output. Parsed/structured output is stored in the `Result` object in the + `structured_result` attribute. Alternatively you can use the `textfsm_parse_output` method of the driver to parse + output in a more manual fashion. This method accepts the string command (channel_input) and the text result and + returns structured data; the driver is already configured with the ntc-templates device type to find the correct + template. + +```python +from nssh.driver.core import IOSXEDriver + +my_device = {"host": "172.18.0.11", "auth_username": "vrnetlab", "auth_password": "VR-netlab9"} + +with IOSXEDriver(**my_device) as conn: + results = conn.send_commands("show version", textfsm=True) + print(results[0].structured_result) + # or parse manually... + results = conn.send_commands("show version") + structured_output = conn.textfsm_parse_output("show version", results[0].result) +``` + +nssh also supports passing in templates manually (meaning not using the pip installed ntc-templates directory to + find templates) if desired. The `nssh.helper.textfsm_parse` function accepts a string or loaded (TextIOWrapper + ) template and output to parse. This can be useful if you have custom or one off templates or don't want to pip + install ntc-templates. + +```python +from nssh.driver.core import IOSXEDriver +from nssh.helper import textfsm_parse + +my_device = {"host": "172.18.0.11", "auth_username": "vrnetlab", "auth_password": "VR-netlab9"} + +with IOSXEDriver(**my_device) as conn: + results = conn.send_commands("show version") + structured_result = textfsm_parse("/path/to/my/template", results[0].result) +``` + +*NOTE*: If a template does not return structured data an empty dict will be returned! + + +## Timeouts + +nssh supports several timeout options. The simplest is the `timeout_socket` which controls the timeout for... setting + up the underlying socket in seconds. Value should be a positive, non-zero number, however ssh2 and paramiko + transport options support floats. + +`timeout_ssh` sets the timeout for the actual SSH session when using ssh2 or paramiko transport options. When using + system SSH, this is currently only used as the timeout timer for authentication. + +Finally, `timeout_ops` sets a timeout value for individual operations -- or put another way, the timeout for each + send_input operation. + + +## Disabling Paging + +nssh native driver attempts to send `terminal length 0` to disable paging by default. In the future this will + likely be removed and relegated to the device drivers only. For all drivers, there is a standard disable paging + string already configured for you, however this is of course user configurable. In addition to passing a string to + send to disable paging, nssh supports passing a callable. This callable should accept the drivers reference to + self as the only argument. This allows for users to create a custom function to disable paging however they like + . This callable option is supported on the native driver as well. In general it is probably a better idea to + handle this by simply passing a string, but the goal is to be flexible so the callable is supported. + +```python +from nssh.driver.core import IOSXEDriver + +def iosxe_disable_paging(cls): + cls.send_commands("term length 0") + +my_device = {"host": "172.18.0.11", "auth_username": "vrnetlab", "auth_password": "VR-netlab9", "session_disable_paging": iosxe_disable_paging} + +with IOSXEDriver(**my_device) as conn: + print(conn.get_prompt()) +``` + + +## Login Handlers + +Some devices have additional prompts or banners at login. This generally causes issues for SSH screen scraping + automation. nssh supports -- just like disable paging -- passing a string to send or a callable to execute after + successful SSH connection but before disabling paging occurs. By default this is an empty string which does nothing. + + +## SSH Config Support + +nssh supports using OpenSSH configuration files in a few ways. For "system" SSH driver, passing a path to a config + file will simply make nssh "point" to that file, and therefore use that configuration files attributes (because it + is just exec'ing system SSH!). Soon SSH support that exists in ssh2net will be ported over to nssh for ssh2-python + and paramiko transport drivers. + +*NOTE* -- when using the system (default) SSH transport driver nssh does NOT disable strict host checking by default +. Obviously this is the "smart" behavior, but it can be overridden on a per host basis in your SSH config file, or by + passing `False` to the "auth_strict_key" argument on object instantiation. + +```python +from nssh.driver.core import IOSXEDriver + +my_device = {"host": "172.18.0.11", "ssh_config_file": "~/mysshconfig", "auth_strict_key": False, "auth_password": "VR-netlab9"} + +with IOSXEDriver(**my_device) as conn: + print(conn.get_prompt()) +``` + + +# FAQ + +- Question: Why build this? Netmiko exists, Paramiko exists, Ansible exists, etc...? + - Answer: I built ssh2net to learn -- to have a goal/target for writing some code. nssh is an evolution of the + lessons learned building ssh2net. About mid-way through building ssh2net I realized it may actually be kinda good + at doing... stuff. So, sure there are other tools out there, but I think nssh its pretty snazzy and fills in some + of the gaps in other tools. For example nssh is 100% compliant with strict mypy type checking, very uniformly + documented/linted, contains a results object for every operation, is very very fast, is very flexible, and in + general pretty awesome! Finally, while I think in general that SSH "screen scraping" is not "sexy" or even + "good", it is the lowest common denominator for automation in the networking world. So I figured I could try + to make the fastest, most flexible library around for SSH network automation! +- Question: Is this better than Netmiko/Paramiko/Ansible? + - Answer: Nope! It is different though! The main focus is just to be stupid fast. It is very much that. It *should + * be super reliable too as the timeouts are very easy/obvious to control, and it should also be very very very easy + to adapt to any other network-y type CLI. +- Question: Is this easy to use? + - Answer: Yep! The "native" usage is pretty straight forward -- the thing to remember is that it doesn't do "things + " for you like Netmiko does for example, so its a lot more like Paramiko in that regard. That said you can use one + of the available drivers to have a more Netmiko-like experience -OR- write your own driver as this has been built + with the thought of being easily extended. +- Why do I get a "conn (or your object name here) has no attribute channel" exception when using the base `NSSH` or + `NetworkDriver` objects? + - Answer: Those objects do not "auto open", and the channel attribute is not assigned until opening the connection + . Call `conn.open()` (or your object name in place of conn) to open the session and assign the channel attribute. +- Other questions? Ask away! + + +# Known Issues + +## SSH2-Python + +Arista EOS uses keyboard interactive authentication which is currently broken in the pip-installable version + of ssh2-python (as of January 2020). GitHub user [Red-M](https://github.com/Red-M) has contributed to and fixed this + particular issue but the fix has not been merged. If you would like to use ssh2-python with EOS I suggest cloning + and installing via Red-M's repository or my fork of Red-M's fork! + +- Use the context manager where possible! More testing needs to be done to confirm/troubleshoot, but limited testing + seems to indicate that without properly closing the connection there appears to be a bug that causes Python to crash + on MacOS at least. More to come on this as I have time to poke it more! + + +# Linting and Testing + +## Linting + +This project uses [black](https://github.com/psf/black) for auto-formatting. In addition to black, tox will execute + [pylama](https://github.com/klen/pylama), and [pydocstyle](https://github.com/PyCQA/pydocstyle) for linting purposes + . Tox will also run [mypy](https://github.com/python/mypy), with strict type checking. Docstring linting with + [darglint](https://github.com/terrencepreilly/darglint) which has been quite handy! + +All commits to this repository will trigger a GitHub action which runs tox, but of course its nicer to just run that + before making a commit to ensure that it will pass all tests! + + +### Typing + +As stated, this project is 100% type checked and will remain that way. The value this adds for IDE auto-completion + and just general sanity checking/forcing writing of more type-check-able code is worth the small overhead in effort. + +## Testing + +I broke testing into two main categories -- unit and functional. Unit is what you would expect -- unit testing the code. + Functional testing connects to virtual devices in order to more accurately test the code. Unit tests cover quite a + bit of the code base due to mocking the FileIO that the channel reads/writes to. This gives a pretty high level of + confidence that at least object instantiation and channel read/writes will generally work... Functional tests + against virtual devices helps reinforce that and gets coverage for the transport classes. + +### Unit Tests + +Unit tests can be executed via pytest: + +``` +python -m pytest tests/unit/ +``` + +Or using the following make command: + +``` +make test_unit +``` + +If you would like to see the coverage report and generate the html coverage report: + +``` +make cov_unit +``` + + +### Setting up Functional Test Environment + + +Executing the functional tests is a bit more complicated! First, thank you to Kristian Larsson for his great tool [vrnetlab](https://github.com/plajjan/vrnetlab)! All functional tests are built on this awesome platform that allows for easy creation of containerized network devices. + +Basic functional tests exist for all "core" platform types (IOSXE, NXOS, IOSXR, EOS, Junos). Vrnetlab currently only supports the older emulation style NX-OS devices, and *not* the newer VM image n9kv. I have made some very minor tweaks to vrnetlab locally in order to get the n9kv image running -- I have raised a PR to add this to vrnetlab proper. Minus the n9kv tweaks, getting going with vrnetlab is fairly straightforward -- simply follow Kristian's great readme docs. For the Arista EOS image -- prior to creating the container you should boot the device and enter the `zerotouch disable` command. This allows for the config to actually be saved and prevents the interfaces from cycling through interface types in the container (I'm not clear why it does that but executing this command before building the container "fixes" this!). After creating the image(s) that you wish to test, rename the image to the following format: + +``` +nssh[PLATFORM] +``` + +The docker-compose file here will be looking for the container images matching this pattern, so this is an important bit! The container image names should be: + +``` +nsshciscoiosxe +nsshcisconxos +nsshciscoiosxr +nsshciscojunos +``` + +You can tag the image names on creation (following the vrnetlab readme docs), or create a new tag once the image is built: + +``` +docker tag [TAG OF IMAGE CREATED] nssh[VENDOR][OS] +``` + +### Functional Tests + +Once you have created the images, you can start the containers with a make command: + +``` +make start_dev_env +``` + +Conversely you can terminate the containers: + +``` +make stop_dev_env +``` + +To start a specific platform container: + +``` +make start_dev_env_iosxe +``` + +Substitute "iosxe" for the platform type you want to start. + +Most of the containers don't take too long to fire up, maybe a few minutes (running on my old macmini with Ubuntu, so not exactly a powerhouse!). That said, the IOS-XR device takes about 15 minutes to go to "healthy" status. Once booted up you can connect to their console or via SSH: + +| Device | Local IP | +| --------------|---------------| +| iosxe | 172.18.0.11 | +| nxos | 172.18.0.12 | +| iosxr | 172.18.0.13 | +| eos | 172.18.0.14 | +| junos | 172.18.0.15 | + +The console port for all devices is 5000, so to connect to the console of the iosxe device you can simply telnet to that port locally: + +``` +telnet 172.18.0.11 5000 +``` + +Credentials for all devices use the default vrnetlab credentials: + +Username: `vrnetlab` + +Password: `VR-netlab9` + +Once the container(s) are ready, you can use the make commands to execute tests as needed: + +- `test` will execute all currently implemented functional tests as well as the unit tests +- `test_functional` will execute all currently implemented functional tests +- `test_iosxe` will execute all unit tests and iosxe functional tests +- `test_nxos` will execute all unit tests and nxos functional tests +- `test_iosxr` will execute all unit tests and iosxr functional tests +- `test_eos` will execute all unit tests and eos functional tests +- `test_junos` will execute all unit tests and junos functional tests diff --git a/examples/basic_usage/iosxe_driver.py b/examples/basic_usage/iosxe_driver.py new file mode 100644 index 00000000..dc31e69e --- /dev/null +++ b/examples/basic_usage/iosxe_driver.py @@ -0,0 +1,9 @@ +from nssh.driver.core import IOSXEDriver + +args = {"host": "172.18.0.11", "auth_username": "vrnetlab", "auth_password": "VR-netlab9"} + +with IOSXEDriver(**args) as conn: + # Platform drivers will auto-magically handle disabling paging for you + result = conn.channel.send_inputs("show run") + +print(result[0].result) diff --git a/examples/basic_usage/nssh_driver.py b/examples/basic_usage/nssh_driver.py new file mode 100644 index 00000000..b47955db --- /dev/null +++ b/examples/basic_usage/nssh_driver.py @@ -0,0 +1,20 @@ +from nssh import NSSH + +args = {"host": "172.18.0.11", "auth_username": "vrnetlab", "auth_password": "VR-netlab9"} + +conn = NSSH(**args) +conn.open() + +print(conn.channel.get_prompt()) +print(conn.channel.send_inputs("show run | i hostname")[0].result) + +# paging is NOT disabled w/ nssh driver! +conn.channel.send_inputs("terminal length 0") +print(conn.channel.send_inputs("show run")[0].result) +conn.close() + + +# Context manager is a great way to use nssh: +with NSSH(**args) as conn: + result = conn.channel.send_inputs("show run | i hostname") +print(result[0].result) diff --git a/examples/logging/basic_logging.py b/examples/logging/basic_logging.py new file mode 100644 index 00000000..c4ab2419 --- /dev/null +++ b/examples/logging/basic_logging.py @@ -0,0 +1,14 @@ +import logging + +from nssh import NSSH + +logging.basicConfig(filename="nssh.log", level=logging.DEBUG) +logger = logging.getLogger("nssh") + +args = {"host": "172.18.0.11", "auth_username": "vrnetlab", "auth_password": "VR-netlab9"} + +conn = NSSH(**args) +conn.open() + +print(conn.channel.get_prompt()) +print(conn.channel.send_inputs("show run | i hostname")[0].result) diff --git a/examples/ssh_keys/ssh_keys.py b/examples/ssh_keys/ssh_keys.py new file mode 100644 index 00000000..b5a76e5f --- /dev/null +++ b/examples/ssh_keys/ssh_keys.py @@ -0,0 +1,9 @@ +from nssh.driver.core import IOSXEDriver + +args = {"host": "172.18.0.11", "auth_username": "vrnetlab", "auth_public_key": "/path/to/your/key"} + +with IOSXEDriver(**args) as conn: + # Platform drivers will auto-magically handle disabling paging for you + result = conn.channel.send_inputs("show run") + +print(result[0].result) diff --git a/nssh/__init__.py b/nssh/__init__.py index 7ab14b25..1ca90c4a 100644 --- a/nssh/__init__.py +++ b/nssh/__init__.py @@ -5,7 +5,7 @@ from nssh.driver import NSSH -__version__ = "2020.01.21" +__version__ = "2020.02.01" __all__ = ("NSSH",) @@ -45,7 +45,7 @@ def filter(self, record: logging.LogRecord) -> bool: return False -# Setup transport logger +# Setup channel logger TRANSPORT_LOG = logging.getLogger("channel") # Add duplicate filter to channel log TRANSPORT_LOG.addFilter(DuplicateFilter()) diff --git a/nssh/channel/channel.py b/nssh/channel/channel.py index 085e2abb..bb21d9bb 100644 --- a/nssh/channel/channel.py +++ b/nssh/channel/channel.py @@ -105,12 +105,6 @@ def _restructure_output(self, output: bytes, strip_prompt: bool = False) -> byte """ output = normalize_lines(output) - # TODO -- purge empty rows before actual output - # this was used to remove duplicate line feeds in output, but that causes some issues for - # testing where we want to match the normal output we see as users... so i think this - # should be removed -- or optional? - # output = b"\n".join([row for row in output.splitlines() if row]) - if not strip_prompt: return output @@ -181,10 +175,6 @@ def _read_until_prompt(self, output: bytes = b"", prompt: str = "") -> bytes: while True: output += self._read_chunk() - # we do not need to deal w/ line replacement for the actual output, only for - # parsing if a prompt-like thing is at the end of the output - # TODO -- at one point this was bytes -> str w/ `unicode-escape` have not tested - # on many live devices if keeping this all bytes works!!! output = re.sub(b"\r", b"", output.strip()) channel_match = re.search(prompt_pattern, output) if channel_match: diff --git a/nssh/driver/core/cisco_iosxe/__init__.py b/nssh/driver/core/cisco_iosxe/__init__.py index e69de29b..15ae8d7a 100644 --- a/nssh/driver/core/cisco_iosxe/__init__.py +++ b/nssh/driver/core/cisco_iosxe/__init__.py @@ -0,0 +1,7 @@ +"""nssh.driver.core.cisco_iosxe""" +from nssh.driver.core.cisco_iosxe.driver import PRIVS, IOSXEDriver + +__all__ = ( + "IOSXEDriver", + "PRIVS", +) diff --git a/nssh/driver/core/cisco_iosxe/driver.py b/nssh/driver/core/cisco_iosxe/driver.py index a98bd33c..4053a653 100644 --- a/nssh/driver/core/cisco_iosxe/driver.py +++ b/nssh/driver/core/cisco_iosxe/driver.py @@ -35,7 +35,7 @@ ), "configuration": ( PrivilegeLevel( - r"^[a-z0-9.\-@/:]{1,32}\(config\)#$", + r"^[a-z0-9.\-@/:]{1,32}\(config[a-z0-9.\-@/:]{0,16}\)#$", "configuration", "privilege_exec", "end", diff --git a/nssh/driver/driver.py b/nssh/driver/driver.py index 5f83d23d..6aeaacc2 100644 --- a/nssh/driver/driver.py +++ b/nssh/driver/driver.py @@ -1,4 +1,4 @@ -"""nssh.base""" +"""nssh.driver.driver""" import logging import os import re @@ -48,20 +48,58 @@ def __init__( comms_ansi: bool = False, session_pre_login_handler: Union[str, Callable[..., Any]] = "", session_disable_paging: Union[str, Callable[..., Any]] = "terminal length 0", + ssh_config_file: Union[str, bool] = True, driver: str = "system", ): """ + NSSH Object + + NSSH is the base class for NetworkDriver, and subsequent platform specific drivers + (i.e. IOSXEDriver). NSSH can be used on its own and offers a semi-pexpect like experience in + that it doesn't know or care about privilege levels, platform types, and things like that. Args: + host: host ip/name to connect to + port: port to connect to + auth_username: username for authentication + auth_public_key: path to public key for authentication + auth_password: password for authentication + auth_strict_key: strict host checking or not -- applicable for system ssh driver only + timeout_socket: timeout for establishing socket in seconds + timeout_ssh: timeout for ssh transport in milliseconds + timeout_ops: timeout for ssh channel operations + comms_prompt_pattern: raw string regex pattern -- preferably use `^` and `$` anchors! + this is the single most important attribute here! if this does not match a prompt, + nssh will not work! + IMPORTANT: regex search uses multi-line + case insensitive flags. multi-line allows + for highly reliably matching for prompts after stripping trailing white space, + case insensitive is just a convenience factor so i can be lazy. + comms_return_char: character to use to send returns to host + comms_ansi: True/False strip comms_ansi characters from output + session_pre_login_handler: callable or string that resolves to an importable function to + handle pre-login (pre disable paging) operations + session_disable_paging: callable, string that resolves to an importable function, or + string to send to device to disable paging + ssh_config_file: string to path for ssh config file, True to use default ssh config file + or False to ignore default ssh config file + driver: system|ssh2|paramiko -- type of ssh driver to use + system uses system available ssh (/usr/bin/ssh) + ssh2 uses ssh2-python + paramiko uses... paramiko + choice of driver depends on the features you need. in general system is easiest as + it will just "auto-magically" use your ssh config file (~/.ssh/config or + /etc/ssh/config_file). ssh2 is very very fast as it is a thin wrapper around libssh2 + however it is slightly feature limited. paramiko is slower than ssh2, but has more + features built in (though nssh does not expose/support them all). Returns: N/A # noqa - Raises - N/A # noqa + Raises: + TypeError: if auth_strict_key is not a bool + ValueError: if driver value is invalid """ - # TODO -- docstring self.host = host.strip() if not isinstance(port, int): raise TypeError(f"port should be int, got {type(port)}") @@ -83,10 +121,14 @@ def __init__( self.comms_ansi: bool = False self._setup_comms(comms_prompt_pattern, comms_return_char, comms_ansi) - self.session_pre_login_handler: Union[str, Callable[..., Any]] = "" + self.session_pre_login_handler: Optional[Callable[..., Any]] = None self.session_disable_paging: Union[str, Callable[..., Any]] = "" self._setup_session(session_pre_login_handler, session_disable_paging) + if not isinstance(ssh_config_file, (str, bool)): + raise TypeError(f"ssh_config_file should be str or bool, got {type(ssh_config_file)}") + self.ssh_config_file = ssh_config_file + if driver not in ("ssh2", "paramiko", "system"): raise ValueError(f"transport should be one of ssh2|paramiko|system, got {driver}") self.transport: Transport @@ -99,6 +141,80 @@ def __init__( continue self.channel_args[arg] = getattr(self, arg) + def __enter__(self) -> "NSSH": + """ + Enter method for context manager + + Args: + N/A # noqa + + Returns: + self: instance of self + + Raises: + N/A # noqa + + """ + self.open() + return self + + def __exit__( + self, + exception_type: Optional[Type[BaseException]], + exception_value: Optional[BaseException], + traceback: Optional[TracebackType], + ) -> None: + """ + Exit method to cleanup for context manager + + Args: + exception_type: exception type being raised + exception_value: message from exception being raised + traceback: traceback from exception being raised + + Returns: + N/A # noqa + + Raises: + N/A # noqa + + """ + self.close() + + def __str__(self) -> str: + """ + Magic str method for NSSH + + Args: + N/A # noqa + + Returns: + N/A # noqa + + Raises: + N/A # noqa + + """ + return f"NSSH Object for host {self.host}" + + def __repr__(self) -> str: + """ + Magic repr method for NSSH + + Args: + N/A # noqa + + Returns: + repr: repr for class object + + Raises: + N/A # noqa + + """ + class_dict = self.__dict__.copy() + class_dict["auth_password"] = "********" + return f"NSSH {class_dict}" + def _setup_auth(self, auth_username: str, auth_password: str, auth_public_key: str) -> None: """ Parse and setup auth attributes @@ -177,7 +293,7 @@ def _setup_session( session_pre_login_handler ) else: - self.session_pre_login_handler = "" + self.session_pre_login_handler = None if callable(session_disable_paging): self.session_disable_paging = session_disable_paging if not isinstance(session_disable_paging, str) and not callable(session_disable_paging): @@ -197,8 +313,8 @@ def _setup_session( @staticmethod def _set_session_pre_login_handler( - session_pre_login_handler: Union[Callable[..., Any], str] - ) -> Union[Callable[..., Any], str]: + session_pre_login_handler: Union[str, Callable[..., Any]] + ) -> Optional[Callable[..., Any]]: """ Return session_pre_login_handler argument @@ -306,42 +422,22 @@ def close(self) -> None: """ self.transport.close() - def __enter__(self) -> "NSSH": + def isalive(self) -> bool: """ - Enter method for context manager + Check if underlying socket/channel is alive Args: N/A # noqa Returns: - self: instance of self + alive: True/False if socket/channel is alive Raises: N/A # noqa """ - self.open() - return self - - def __exit__( - self, - exception_type: Optional[Type[BaseException]], - exception_value: Optional[BaseException], - traceback: Optional[TracebackType], - ) -> None: - """ - Exit method to cleanup for context manager - - Args: - exception_type: exception type being raised - exception_value: message from exception being raised - traceback: traceback from exception being raised - - Returns: - N/A # noqa - - Raises: - N/A # noqa - - """ - self.close() + try: + alive = self.transport.isalive() + except AttributeError: + alive = False + return alive diff --git a/nssh/driver/network_driver.py b/nssh/driver/network_driver.py index 08008502..aa5122b0 100644 --- a/nssh/driver/network_driver.py +++ b/nssh/driver/network_driver.py @@ -1,4 +1,4 @@ -"""nssh.base""" +"""nssh.driver.network_driver""" import collections import logging import re @@ -200,6 +200,7 @@ def send_commands( Raises: N/A # noqa + """ self.acquire_priv(str(self.default_desired_priv)) results = self.channel.send_inputs(commands, strip_prompt) @@ -226,6 +227,7 @@ def send_configs( Raises: N/A # noqa + """ self.acquire_priv("configuration") result = self.channel.send_inputs(configs, strip_prompt) @@ -249,6 +251,7 @@ def textfsm_parse_output( Raises: N/A # noqa + """ template = _textfsm_get_template(self.textfsm_platform, command) if isinstance(template, TextIOWrapper): @@ -256,3 +259,31 @@ def textfsm_parse_output( if isinstance(structured_output, (dict, list)): return structured_output return {} + + def get_prompt(self) -> str: + """ + Convenience method to get device prompt from Channel + + Args: + N/A # noqa + + Returns: + prompt: prompt received from channel.get_prompt + + Raises: + N/A # noqa + + """ + prompt: str = self.channel.get_prompt() + return prompt + + def open(self) -> None: + super().open() + if self.session_pre_login_handler: + self.session_pre_login_handler(self) + # send disable paging if needed + if self.session_disable_paging: + if callable(self.session_disable_paging): + self.session_disable_paging(self) + else: + self.channel.send_inputs(self.session_disable_paging) diff --git a/nssh/transport/cssh2.py b/nssh/transport/cssh2.py index 3453513c..d1a47b1b 100644 --- a/nssh/transport/cssh2.py +++ b/nssh/transport/cssh2.py @@ -27,11 +27,11 @@ def __init__( self, host: str, port: int = 22, - timeout_ssh: int = 5000, - timeout_socket: int = 5, auth_username: str = "", auth_public_key: str = "", auth_password: str = "", + timeout_ssh: int = 5000, + timeout_socket: int = 5, ): """ SSH2Transport Object @@ -43,11 +43,11 @@ def __init__( Args: host: host ip/name to connect to port: port to connect to - timeout_ssh: timeout for ssh2 transport in milliseconds - timeout_socket: timeout for establishing socket in seconds auth_username: username for authentication auth_public_key: path to public key for authentication auth_password: password for authentication + timeout_ssh: timeout for ssh2 transport in milliseconds + timeout_socket: timeout for establishing socket in seconds Returns: N/A # noqa diff --git a/nssh/transport/miko.py b/nssh/transport/miko.py index 170067a1..b83d4413 100644 --- a/nssh/transport/miko.py +++ b/nssh/transport/miko.py @@ -27,11 +27,11 @@ def __init__( self, host: str, port: int = 22, - timeout_ssh: int = 5000, - timeout_socket: int = 5, auth_username: str = "", auth_public_key: str = "", auth_password: str = "", + timeout_ssh: int = 5000, + timeout_socket: int = 5, ): """ MikoTransport Object @@ -43,11 +43,11 @@ def __init__( Args: host: host ip/name to connect to port: port to connect to - timeout_ssh: timeout for ssh2 transport in milliseconds - timeout_socket: timeout for establishing socket in seconds auth_username: username for authentication auth_public_key: path to public key for authentication auth_password: password for authentication + timeout_socket: timeout for establishing socket in seconds + timeout_ssh: timeout for ssh transport in milliseconds Returns: N/A # noqa diff --git a/nssh/transport/systemssh.py b/nssh/transport/systemssh.py index 7b04757d..ded690b1 100644 --- a/nssh/transport/systemssh.py +++ b/nssh/transport/systemssh.py @@ -22,12 +22,14 @@ SYSTEM_SSH_TRANSPORT_ARGS = ( "host", "port", + "timeout_socket", "timeout_ssh", "auth_username", "auth_public_key", "auth_password", "auth_strict_key", "comms_return_char", + "ssh_config_file", ) @@ -36,13 +38,15 @@ def __init__( self, host: str, port: int = 22, - timeout_ssh: int = 5000, auth_username: str = "", auth_public_key: str = "", auth_password: str = "", auth_strict_key: bool = True, + timeout_socket: int = 5, + timeout_ssh: int = 5000, comms_prompt_pattern: str = r"^[a-z0-9.\-@()/:]{1,32}[#>$]$", comms_return_char: str = "\n", + ssh_config_file: Union[str, bool] = False, ): # pylint: disable=W0231 """ SystemSSHTransport Object @@ -53,11 +57,12 @@ def __init__( Args: host: host ip/name to connect to port: port to connect to - timeout_ssh: timeout for ssh2 transport in milliseconds auth_username: username for authentication auth_public_key: path to public key for authentication auth_password: password for authentication auth_strict_key: True/False to enforce strict key checking (default is True) + timeout_socket: timeout for establishing socket in seconds + timeout_ssh: timeout for ssh transport in milliseconds comms_prompt_pattern: prompt pattern expected for device, same as the one provided to channel -- system ssh needs to know this to know how to decide if we are properly sending/receiving data -- i.e. we are not stuck at some password prompt or some @@ -68,6 +73,8 @@ def __init__( the channel to make sure we are authenticated and sending/receiving data. If using driver, this should be passed from driver (NSSH, or IOSXE, etc.) to this Transport class. + ssh_config_file: string to path for ssh config file, True to use default ssh config file + or False to ignore default ssh config file Returns: N/A # noqa @@ -78,7 +85,8 @@ def __init__( """ self.host: str = host self.port: int = port - self.timeout_ssh: int = timeout_ssh + self.timeout_socket: int = timeout_socket + self.timeout_ssh: int = int(timeout_ssh / 1000) self.session_lock: Lock = Lock() self.auth_username: str = auth_username self.auth_public_key: str = auth_public_key @@ -86,6 +94,7 @@ def __init__( self.auth_strict_key: bool = auth_strict_key self.comms_prompt_pattern: str = comms_prompt_pattern self.comms_return_char: str = comms_return_char + self.ssh_config_file: Union[str, bool] = ssh_config_file self.session: Union[Popen[bytes], PtyProcess] # pylint: disable=E1136 self.lib_auth_exception = NSSHAuthenticationFailed @@ -108,14 +117,16 @@ def _build_open_cmd(self) -> None: N/A # noqa """ - # TODO -- need to handle ssh config, proxy, other cli args... self.open_cmd.extend(["-p", str(self.port)]) + self.open_cmd.extend(["-o", f"ConnectTimeout={self.timeout_socket}"]) if self.auth_public_key: self.open_cmd.extend(["-i", self.auth_public_key]) if self.auth_username: self.open_cmd.extend(["-l", self.auth_username]) if self.auth_strict_key is False: self.open_cmd.extend(["-o", "StrictHostKeyChecking=no"]) + if isinstance(self.ssh_config_file, str): + self.open_cmd.extend(["-F", self.ssh_config_file]) def open(self) -> None: """ @@ -281,8 +292,7 @@ def _pty_isauthenticated(self, pty_session: PtyProcess) -> bool: pty_session.write(self.comms_return_char.encode()) fd_ready, _, _ = select([pty_session.fd], [], [], 0) if pty_session.fd in fd_ready: - # TODO -- it seems that i need two reads here...? doesn't seem to be a problem - # elsewhere though...? not sure whats going on + # unclear as to why there needs to be two read operations here, but fails w/out it pty_session.read() output = pty_session.read() # we do not need to deal w/ line replacement for the actual output, only for @@ -396,7 +406,7 @@ def flush(self) -> None: """ if isinstance(self.session, Popen): - # TODO -- is this ok? should i read off the channel? + # flush seems to be unnecessary for Popen sessions pass elif isinstance(self.session, PtyProcess): self.session.flush() @@ -415,7 +425,6 @@ def set_timeout(self, timeout: Optional[int] = None) -> None: N/A # noqa """ - # TODO would be good to be able to set this?? Or is it unnecessary for popen?? def set_blocking(self, blocking: bool = False) -> None: """ diff --git a/setup.cfg b/setup.cfg index 27908736..900dcabe 100644 --- a/setup.cfg +++ b/setup.cfg @@ -3,7 +3,7 @@ omit = nssh/transport/ptyprocess.py [pylama] linters = mccabe,pycodestyle,pylint -skip = tests/*,.tox/*,venv/*,build/*,private/*,nssh/transport/ptyprocess.py +skip = tests/*,.tox/*,venv/*,build/*,private/*,examples/*,nssh/transport/ptyprocess.py [pylama:pycodestyle] max_line_length = 100 diff --git a/setup.py b/setup.py index d08f7ccd..c06e5613 100644 --- a/setup.py +++ b/setup.py @@ -9,7 +9,7 @@ setuptools.setup( name="nssh", - version="2020.01.27", + version="2020.02.01", author=__author__, author_email="carl.r.montanari@gmail.com", description="SSH client focused on network devices", diff --git a/tests/functional/conftest.py b/tests/functional/conftest.py index 0e1ff91d..a4fbe067 100644 --- a/tests/functional/conftest.py +++ b/tests/functional/conftest.py @@ -1,6 +1,8 @@ import pytest from nssh import NSSH +from nssh.driver import NetworkDriver +from nssh.driver.core import IOSXEDriver @pytest.fixture(scope="module") @@ -11,7 +13,7 @@ def _base_driver( auth_username="", auth_password="", auth_public_key="", - timeout_socket=1, + timeout_socket=5, timeout_ssh=1000, timeout_ops=2, comms_prompt_pattern=r"^[a-z0-9.\-@()/:]{1,32}[#>$]$", @@ -39,3 +41,79 @@ def _base_driver( return conn return _base_driver + + +@pytest.fixture(scope="module") +def network_driver(): + def _network_driver( + host, + port=22, + auth_username="", + auth_password="", + auth_public_key="", + timeout_socket=5, + timeout_ssh=1000, + timeout_ops=2, + comms_prompt_pattern=r"^[a-z0-9.\-@()/:]{1,32}[#>$]$", + comms_ansi=False, + session_pre_login_handler="", + session_disable_paging="terminal length 0", + driver="system", + ): + conn = NetworkDriver( + host=host, + port=port, + auth_username=auth_username, + auth_password=auth_password, + auth_public_key=auth_public_key, + timeout_socket=timeout_socket, + timeout_ssh=timeout_ssh, + timeout_ops=timeout_ops, + comms_prompt_pattern=comms_prompt_pattern, + comms_ansi=comms_ansi, + session_pre_login_handler=session_pre_login_handler, + session_disable_paging=session_disable_paging, + driver=driver, + ) + conn.open() + return conn + + return _network_driver + + +@pytest.fixture(scope="module") +def cisco_iosxe_driver(): + def _cisco_iosxe_driver( + host, + port=22, + auth_username="", + auth_password="", + auth_public_key="", + timeout_socket=5, + timeout_ssh=1000, + timeout_ops=2, + comms_prompt_pattern=r"^[a-z0-9.\-@()/:]{1,32}[#>$]$", + comms_ansi=False, + session_pre_login_handler="", + session_disable_paging="terminal length 0", + driver="system", + ): + conn = IOSXEDriver( + host=host, + port=port, + auth_username=auth_username, + auth_password=auth_password, + auth_public_key=auth_public_key, + timeout_socket=timeout_socket, + timeout_ssh=timeout_ssh, + timeout_ops=timeout_ops, + comms_prompt_pattern=comms_prompt_pattern, + comms_ansi=comms_ansi, + session_pre_login_handler=session_pre_login_handler, + session_disable_paging=session_disable_paging, + driver=driver, + ) + conn.open() + return conn + + return _cisco_iosxe_driver diff --git a/tests/functional/driver/core/__init__.py b/tests/functional/driver/core/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/functional/driver/core/cisco_iosxe/__init__.py b/tests/functional/driver/core/cisco_iosxe/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/functional/driver/iosxe_helper.py b/tests/functional/driver/core/cisco_iosxe/helper.py similarity index 100% rename from tests/functional/driver/iosxe_helper.py rename to tests/functional/driver/core/cisco_iosxe/helper.py diff --git a/tests/functional/driver/core/cisco_iosxe/test_driver.py b/tests/functional/driver/core/cisco_iosxe/test_driver.py new file mode 100644 index 00000000..4bd19190 --- /dev/null +++ b/tests/functional/driver/core/cisco_iosxe/test_driver.py @@ -0,0 +1,59 @@ +import json +from pathlib import Path + +import pytest + +import nssh + +from .helper import clean_output_data + +TEST_DATA_PATH = f"{Path(nssh.__file__).parents[1]}/tests/functional/test_data" +with open(f"{TEST_DATA_PATH}/devices/cisco_iosxe.json", "r") as f: + CISCO_IOSXE_DEVICE = json.load(f) +with open(f"{TEST_DATA_PATH}/test_cases/cisco_iosxe.json", "r") as f: + test_cases = json.load(f) + CISCO_IOSXE_TEST_CASES = test_cases["test_cases"] + +TEST_CASES = {"cisco_iosxe": CISCO_IOSXE_TEST_CASES} + + +@pytest.mark.parametrize( + "test", + [t for t in CISCO_IOSXE_TEST_CASES["send_commands"]["tests"]], + ids=[n["name"] for n in CISCO_IOSXE_TEST_CASES["send_commands"]["tests"]], +) +@pytest.mark.parametrize( + "driver", ["system", "ssh2", "paramiko"], ids=["system", "ssh2", "paramiko"] +) +def test_send_commands(cisco_iosxe_driver, driver, test): + conn = cisco_iosxe_driver(**CISCO_IOSXE_DEVICE, driver=driver) + results = conn.send_commands(test["inputs"], **test["kwargs"]) + + for index, result in enumerate(results): + cleaned_result = clean_output_data(test, result.result) + assert cleaned_result == test["outputs"][index] + if test.get("textfsm", None): + assert isinstance(result.structured_result, (list, dict)) + conn.close() + + +@pytest.mark.parametrize( + "driver", ["system", "ssh2", "paramiko"], ids=["system", "ssh2", "paramiko"] +) +def test__acquire_priv_escalate(cisco_iosxe_driver, driver): + conn = cisco_iosxe_driver(**CISCO_IOSXE_DEVICE, driver=driver) + conn.acquire_priv("configuration") + current_priv = conn._determine_current_priv(conn.get_prompt()) + assert current_priv.name == "configuration" + conn.close() + + +@pytest.mark.parametrize( + "driver", ["system", "ssh2", "paramiko"], ids=["system", "ssh2", "paramiko"] +) +def test__acquire_priv_deescalate(cisco_iosxe_driver, driver): + conn = cisco_iosxe_driver(**CISCO_IOSXE_DEVICE, driver=driver) + conn.acquire_priv("exec") + current_priv = conn._determine_current_priv(conn.get_prompt()) + assert current_priv.name == "exec" + conn.close() diff --git a/tests/functional/driver/test_driver.py b/tests/functional/driver/test_driver.py index ffcb18f4..9afdde3c 100644 --- a/tests/functional/driver/test_driver.py +++ b/tests/functional/driver/test_driver.py @@ -5,7 +5,7 @@ import nssh -from .iosxe_helper import clean_output_data +from .core.cisco_iosxe.helper import clean_output_data TEST_DATA_PATH = f"{Path(nssh.__file__).parents[1]}/tests/functional/test_data" with open(f"{TEST_DATA_PATH}/devices/cisco_iosxe.json", "r") as f: @@ -24,6 +24,7 @@ def test_get_prompt(base_driver, driver): conn = base_driver(**CISCO_IOSXE_DEVICE, driver=driver) result = conn.channel.get_prompt() assert result == "csr1000v#" + conn.close() @pytest.mark.parametrize( diff --git a/tests/functional/driver/test_network_driver.py b/tests/functional/driver/test_network_driver.py new file mode 100644 index 00000000..2b5446c7 --- /dev/null +++ b/tests/functional/driver/test_network_driver.py @@ -0,0 +1,109 @@ +import json +from pathlib import Path + +import pytest + +import nssh +from nssh.driver.core.cisco_iosxe import PRIVS as CISCO_IOSXE_PRIVS + +from .core.cisco_iosxe.helper import clean_output_data + +TEST_DATA_PATH = f"{Path(nssh.__file__).parents[1]}/tests/functional/test_data" +with open(f"{TEST_DATA_PATH}/devices/cisco_iosxe.json", "r") as f: + CISCO_IOSXE_DEVICE = json.load(f) +with open(f"{TEST_DATA_PATH}/test_cases/cisco_iosxe.json", "r") as f: + test_cases = json.load(f) + CISCO_IOSXE_TEST_CASES = test_cases["test_cases"] + +TEST_CASES = {"cisco_iosxe": CISCO_IOSXE_TEST_CASES} + + +@pytest.mark.parametrize( + "driver", ["system", "ssh2", "paramiko"], ids=["system", "ssh2", "paramiko"] +) +def test_get_prompt(base_driver, driver): + conn = base_driver(**CISCO_IOSXE_DEVICE, driver=driver) + result = conn.channel.get_prompt() + assert result == "csr1000v#" + conn.close() + + +@pytest.mark.parametrize( + "test", + [t for t in CISCO_IOSXE_TEST_CASES["channel.send_inputs"]["tests"]], + ids=[n["name"] for n in CISCO_IOSXE_TEST_CASES["channel.send_inputs"]["tests"]], +) +@pytest.mark.parametrize( + "driver", ["system", "ssh2", "paramiko"], ids=["system", "ssh2", "paramiko"] +) +def test_channel_send_inputs(base_driver, driver, test): + conn = base_driver(**CISCO_IOSXE_DEVICE, driver=driver) + results = conn.channel.send_inputs(test["inputs"], **test["kwargs"]) + for index, result in enumerate(results): + cleaned_result = clean_output_data(test, result.result) + assert cleaned_result == test["outputs"][index] + conn.close() + + +@pytest.mark.parametrize( + "test", + [t for t in CISCO_IOSXE_TEST_CASES["channel.send_inputs_interact"]["tests"]], + ids=[n["name"] for n in CISCO_IOSXE_TEST_CASES["channel.send_inputs_interact"]["tests"]], +) +@pytest.mark.parametrize( + "driver", ["system", "ssh2", "paramiko"], ids=["system", "ssh2", "paramiko"] +) +def test_channel_send_inputs_interact(base_driver, driver, test): + conn = base_driver(**CISCO_IOSXE_DEVICE, driver=driver) + results = conn.channel.send_inputs_interact(test["inputs"]) + cleaned_result = clean_output_data(test, results[0].result) + assert cleaned_result == test["outputs"][0] + conn.close() + + +@pytest.mark.parametrize( + "test", + [t for t in CISCO_IOSXE_TEST_CASES["send_commands"]["tests"]], + ids=[n["name"] for n in CISCO_IOSXE_TEST_CASES["send_commands"]["tests"]], +) +@pytest.mark.parametrize( + "driver", ["system", "ssh2", "paramiko"], ids=["system", "ssh2", "paramiko"] +) +def test_send_commands(network_driver, driver, test): + conn = network_driver(**CISCO_IOSXE_DEVICE, driver=driver) + conn.default_desired_priv = "privilege_exec" + conn.privs = CISCO_IOSXE_PRIVS + results = conn.send_commands(test["inputs"], **test["kwargs"]) + + for index, result in enumerate(results): + cleaned_result = clean_output_data(test, result.result) + assert cleaned_result == test["outputs"][index] + if test.get("textfsm", None): + assert isinstance(result.structured_result, (list, dict)) + conn.close() + + +@pytest.mark.parametrize( + "driver", ["system", "ssh2", "paramiko"], ids=["system", "ssh2", "paramiko"] +) +def test__acquire_priv_escalate(network_driver, driver): + conn = network_driver(**CISCO_IOSXE_DEVICE, driver=driver) + conn.default_desired_priv = "privilege_exec" + conn.privs = CISCO_IOSXE_PRIVS + conn.acquire_priv("configuration") + current_priv = conn._determine_current_priv(conn.get_prompt()) + assert current_priv.name == "configuration" + conn.close() + + +@pytest.mark.parametrize( + "driver", ["system", "ssh2", "paramiko"], ids=["system", "ssh2", "paramiko"] +) +def test__acquire_priv_deescalate(network_driver, driver): + conn = network_driver(**CISCO_IOSXE_DEVICE, driver=driver) + conn.default_desired_priv = "privilege_exec" + conn.privs = CISCO_IOSXE_PRIVS + conn.acquire_priv("exec") + current_priv = conn._determine_current_priv(conn.get_prompt()) + assert current_priv.name == "exec" + conn.close() diff --git a/tests/functional/test_data/test_cases/cisco_iosxe.json b/tests/functional/test_data/test_cases/cisco_iosxe.json index b3592cd1..2b2c60ad 100644 --- a/tests/functional/test_data/test_cases/cisco_iosxe.json +++ b/tests/functional/test_data/test_cases/cisco_iosxe.json @@ -24,7 +24,9 @@ "replace_timestamps": false, "replace_cfg_by": false, "replace_crypto": false, - "kwargs": {"strip_prompt": false}, + "kwargs": { + "strip_prompt": false + }, "inputs": [ "show run | i hostname" ], @@ -72,6 +74,72 @@ ] } ] + }, + "send_commands": { + "tests": [ + { + "name": "send command simple", + "notes": "should always work, no disable paging required, if this is broken things are in bad shape!", + "replace_bytes": false, + "replace_timestamps": false, + "replace_cfg_by": false, + "replace_crypto": false, + "kwargs": {}, + "inputs": [ + "show run | i hostname" + ], + "outputs": [ + "hostname csr1000v" + ] + }, + { + "name": "send command simple, don't strip prompt", + "notes": "should always work, no disable paging required, if this is broken things are in bad shape!", + "replace_bytes": false, + "replace_timestamps": false, + "replace_cfg_by": false, + "replace_crypto": false, + "kwargs": { + "strip_prompt": false + }, + "inputs": [ + "show run | i hostname" + ], + "outputs": [ + "hostname csr1000v\ncsr1000v#" + ] + }, + { + "name": "send command long output", + "notes": "send input test that would require disable paging to have succeeded, base network driver automatically disables paging", + "replace_bytes": true, + "replace_timestamps": true, + "replace_cfg_by": true, + "replace_crypto": true, + "kwargs": {}, + "inputs": [ + "show run" + ], + "outputs": [ + "Building configuration...\nCurrent configuration : CONFIG_BYTES\n!\n! Last configuration change at TIME_STAMP_REPLACED\n!\nversion 16.4\nservice timestamps debug datetime msec\nservice timestamps log datetime msec\nno platform punt-keepalive disable-kernel-core\nplatform console serial\n!\nhostname csr1000v\n!\nboot-start-marker\nboot-end-marker\n!\n!\n!\nno aaa new-model\n!\n!\n!\n!\n!\n!\n!\n!\n!\n\n\n\nip domain name example.com\n!\n!\n!\n!\n!\n!\n!\n!\n!\n!\nsubscriber templating\n!\n!\n!\nmultilink bundle-name authenticated\n!\n!\n!\n!\n!\n!\n\n\n!\n!\n!\n!\n!\n!\n!\nlicense udi pid CSR1000V sn 9FKLJWM5EB0\ndiagnostic bootup level minimal\n!\nspanning-tree extend system-id\nnetconf-yang cisco-odm actions ACL\nnetconf-yang cisco-odm actions BGP\nnetconf-yang cisco-odm actions OSPF\nnetconf-yang cisco-odm actions Archive\nnetconf-yang cisco-odm actions IPRoute\nnetconf-yang cisco-odm actions EFPStats\nnetconf-yang cisco-odm actions IPSLAStats\nnetconf-yang cisco-odm actions Interfaces\nnetconf-yang cisco-odm actions Environment\nnetconf-yang cisco-odm actions FlowMonitor\nnetconf-yang cisco-odm actions MemoryStats\nnetconf-yang cisco-odm actions BFDNeighbors\nnetconf-yang cisco-odm actions BridgeDomain\nnetconf-yang cisco-odm actions CPUProcesses\nnetconf-yang cisco-odm actions LLDPNeighbors\nnetconf-yang cisco-odm actions VirtualService\nnetconf-yang cisco-odm actions MemoryProcesses\nnetconf-yang cisco-odm actions EthernetCFMStats\nnetconf-yang cisco-odm actions MPLSLDPNeighbors\nnetconf-yang cisco-odm actions PlatformSoftware\nnetconf-yang cisco-odm actions MPLSStaticBinding\nnetconf-yang cisco-odm actions MPLSForwardingTable\nnetconf-yang\n!\nrestconf\n!\nusername vrnetlab privilege 15 password 0 VR-netlab9\n!\nredundancy\n!\n!\n!\n!\n!\n!\n!\n!\n!\n!\n!\n!\n!\n!\n!\n!\n!\n!\n!\n!\n!\n!\n!\n!\n!\n!\n!\ninterface GigabitEthernet1\n ip address 10.0.0.15 255.255.255.0\n negotiation auto\n no mop enabled\n no mop sysid\n!\ninterface GigabitEthernet2\n no ip address\n shutdown\n negotiation auto\n no mop enabled\n no mop sysid\n!\ninterface GigabitEthernet3\n no ip address\n shutdown\n negotiation auto\n no mop enabled\n no mop sysid\n!\ninterface GigabitEthernet4\n no ip address\n shutdown\n negotiation auto\n no mop enabled\n no mop sysid\n!\ninterface GigabitEthernet5\n no ip address\n shutdown\n negotiation auto\n no mop enabled\n no mop sysid\n!\ninterface GigabitEthernet6\n no ip address\n shutdown\n negotiation auto\n no mop enabled\n no mop sysid\n!\ninterface GigabitEthernet7\n no ip address\n shutdown\n negotiation auto\n no mop enabled\n no mop sysid\n!\ninterface GigabitEthernet8\n no ip address\n shutdown\n negotiation auto\n no mop enabled\n no mop sysid\n!\ninterface GigabitEthernet9\n no ip address\n shutdown\n negotiation auto\n no mop enabled\n no mop sysid\n!\ninterface GigabitEthernet10\n no ip address\n shutdown\n negotiation auto\n no mop enabled\n no mop sysid\n!\n!\nvirtual-service csr_mgmt\n!\nip forward-protocol nd\nno ip http server\nno ip http secure-server\n!\n!\n!\n!\n!\n!\n!\ncontrol-plane\n!\n !\n !\n !\n !\n!\n!\n!\n!\n!\nline con 0\n stopbits 1\nline vty 0\n login local\n transport input all\nline vty 1\n login local\n length 0\n transport input all\nline vty 2 4\n login local\n transport input all\n!\n!\n!\n!\n!\n!\nend" + ] + }, + { + "name": "send command get structured output", + "notes": "send input and ask for textfsm parsed response in result object", + "replace_bytes": false, + "replace_timestamps": false, + "replace_cfg_by": false, + "replace_crypto": false, + "kwargs": {"textfsm": true}, + "inputs": [ + "show ip route" + ], + "outputs": [ + "Codes: L - local, C - connected, S - static, R - RIP, M - mobile, B - BGP\n D - EIGRP, EX - EIGRP external, O - OSPF, IA - OSPF inter area\n N1 - OSPF NSSA external type 1, N2 - OSPF NSSA external type 2\n E1 - OSPF external type 1, E2 - OSPF external type 2\n i - IS-IS, su - IS-IS summary, L1 - IS-IS level-1, L2 - IS-IS level-2\n ia - IS-IS inter area, * - candidate default, U - per-user static route\n o - ODR, P - periodic downloaded static route, H - NHRP, l - LISP\n a - application route\n + - replicated route, % - next hop override, p - overrides from PfR\n\nGateway of last resort is not set\n\n 10.0.0.0/8 is variably subnetted, 2 subnets, 2 masks\nC 10.0.0.0/24 is directly connected, GigabitEthernet1\nL 10.0.0.15/32 is directly connected, GigabitEthernet1" + ] + } + ] } } } \ No newline at end of file diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 2de438e0..d21a816d 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -4,6 +4,7 @@ import pytest +from nssh.channel import Channel from nssh.driver import NSSH, NetworkDriver from nssh.driver.core.cisco_iosxe.driver import PRIVS from nssh.transport.transport import Transport @@ -51,6 +52,13 @@ def __init__(self, channel_ops, initial_bytes, *args, comms_ansi=False, **kwargs super().__init__(channel_ops, initial_bytes, *args, comms_ansi=comms_ansi, **kwargs) self.privs = PRIVS + def open(self): + # Overriding "normal" network driver open method as we don't need to worry about disable + # paging or pre login handles; ignoring this makes the mocked file read/write pieces simpler + self.transport = self.transport_class(**self.transport_args) + self.transport.open() + self.channel = Channel(self.transport, **self.channel_args) + class MockTransport(Transport): def __init__(self, host, port, timeout_socket, comms_return_char, initial_bytes, channel_ops): diff --git a/tests/unit/driver/test_driver.py b/tests/unit/driver/test_driver.py index e00bbf09..2f5ce292 100644 --- a/tests/unit/driver/test_driver.py +++ b/tests/unit/driver/test_driver.py @@ -6,6 +6,19 @@ from nssh.transport import MikoTransport, SSH2Transport, SystemSSHTransport +def test__str(): + conn = NSSH(host="myhost") + assert str(conn) == "NSSH Object for host myhost" + + +def test__repr(): + conn = NSSH(host="myhost") + assert ( + repr(conn) + == "NSSH {'host': 'myhost', 'port': 22, 'auth_username': '', 'auth_password': '********', 'auth_strict_key': True, 'auth_public_key': b'', 'timeout_socket': 5, 'timeout_ssh': 5000, 'timeout_ops': 10, 'comms_prompt_pattern': '^[a-z0-9.\\\\-@()/:]{1,32}[#>$]$', 'comms_return_char': '\\n', 'comms_ansi': False, 'session_pre_login_handler': None, 'session_disable_paging': 'terminal length 0', 'ssh_config_file': True, 'transport_class': , 'transport_args': {'host': 'myhost', 'port': 22, 'timeout_socket': 5, 'timeout_ssh': 5000, 'auth_username': '', 'auth_public_key': b'', 'auth_password': '', 'auth_strict_key': True, 'comms_return_char': '\\n', 'ssh_config_file': True}, 'channel_args': {'comms_prompt_pattern': '^[a-z0-9.\\\\-@()/:]{1,32}[#>$]$', 'comms_return_char': '\\n', 'comms_ansi': False, 'timeout_ops': 10}}" + ) + + def test_host(): conn = NSSH(host="myhost") assert conn.host == "myhost" @@ -172,7 +185,8 @@ def test_invalid_session_disable_paging(): assert str(e.value) == "session_disable_paging should be str or callable, got " -# TODO -- add isalive and test against that! -def test_close(mocked_channel): +def test_isalive(mocked_channel): + # mocked channel always returns true so this is not a great test conn = mocked_channel([]) - conn.close() + conn.open() + assert conn.isalive() is True diff --git a/tests/unit/transport/test_transport.py b/tests/unit/transport/test_transport.py index 8d1edbf3..a23353d4 100644 --- a/tests/unit/transport/test_transport.py +++ b/tests/unit/transport/test_transport.py @@ -1,4 +1,3 @@ -from threading import Lock from typing import Optional from nssh.transport import Transport