├── .gitignore ├── LICENSE ├── README ├── aruba.py ├── asa.py ├── base_device.py ├── brocade.py ├── brocade_test.py ├── cisconx.py ├── ciscoxr.py ├── fake_ssh_connection.py ├── hp.py ├── ios.py ├── junos.py ├── junos_test.py ├── paramiko_device.py ├── paramiko_device_test.py ├── pexpect_connection.py ├── push.py ├── push_exceptions.py ├── setup.py └── sshclient.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | 3 | Version 2.0, January 2004 4 | 5 | http://www.apache.org/licenses/ 6 | 7 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 8 | 9 | 1. Definitions. 10 | 11 | "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. 16 | 17 | "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. 18 | 19 | "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. 20 | 21 | "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. 22 | 23 | "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). 24 | 25 | "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. 26 | 27 | "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." 28 | 29 | "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 30 | 31 | 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 32 | 33 | 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 34 | 35 | 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: 36 | 37 | You must give any other recipients of the Work or Derivative Works a copy of this License; and 38 | You must cause any modified files to carry prominent notices stating that You changed the files; and 39 | You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and 40 | If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. 41 | 42 | You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 43 | 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 44 | 45 | 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 46 | 47 | 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 48 | 49 | 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 50 | 51 | 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. 52 | -------------------------------------------------------------------------------- /README: -------------------------------------------------------------------------------- 1 | A cross-vendor configuration distribution tool. This is useful for pushing 2 | ACLs or other pieces of configuration to network elements. It can also be used 3 | to send commands to a list of devices and gather the results. 4 | 5 | Install 6 | ------- 7 | 8 | To run this tool you will need to install several modules not part of the 9 | Python standard library, all of which should be easily installable via pip, 10 | easy_install, or distribution package: 11 | 12 | pexpect 13 | paramiko 14 | progressbar 15 | gflags 16 | 17 | Examples 18 | -------- 19 | 20 | Push the configurations in /tmp/foo and /tmp/bar to two devices, 192.168.192.1 21 | and router1.foo.com. Push cannot guess what vendor is in use, but you could 22 | change the default vendor flag at the top if you only use one vendor. This 23 | example forces the use of the username 'dude' rather than your own. 24 | 25 | ./push.py --targets 192.168.192.1,router1.foo.com --vendor ios --user dude \ 26 | /tmp/foo /tmp/bar 27 | 28 | Send a 'show version' command to the list of devices. Output will be sprayed to 29 | STDOUT. Target names must be resolvable on your machine. 30 | 31 | ./push.py --targets r1,r2,r3,r4 --vendor ios --command 'show version' 32 | 33 | Use filenames to determine the name of the target device. The string file name 34 | must be resolvable. 35 | 36 | ./push.py --devices_from_filenames --vendor ios devicefiles/* 37 | -------------------------------------------------------------------------------- /aruba.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # 3 | # Copyright 2014 Google Inc. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | """An Aruba device . 18 | 19 | This module implements the base device interface of base_device.py for 20 | Aruba devices. 21 | """ 22 | import os 23 | 24 | import pexpect 25 | 26 | #import gflags 27 | from absl import flags as gflags 28 | import logging 29 | 30 | import base_device 31 | import pexpect_connection 32 | import push_exceptions as exceptions 33 | 34 | FLAGS = gflags.FLAGS 35 | 36 | gflags.DEFINE_float('aruba_timeout_response', None, 37 | 'Aruba device response timeout in seconds.') 38 | gflags.DEFINE_float('aruba_timeout_connect', None, 39 | 'Aruba device connect timeout in seconds.') 40 | gflags.DEFINE_float('aruba_timeout_idle', None, 41 | 'Aruba device idle timeout in seconds.') 42 | gflags.DEFINE_float('aruba_timeout_disconnect', None, 43 | 'Aruba device disconnect timeout in seconds.') 44 | gflags.DEFINE_float('aruba_timeout_act_user', None, 45 | 'Aruba device user activation timeout in seconds.') 46 | 47 | # Error message format while executing an invalid command eg:. 48 | # (sydpirwmc1) #asdfasd. 49 | # ^ 50 | # % Invalid input detected at '^' marker. 51 | INVALID_OUT1 = "% Invalid input detected at '^' marker.\n\n" 52 | # eg: (sydpirwmc1) #traceroute a. 53 | # Incorrect Input !use traceroute . 54 | INVALID_OUT2 = 'Incorrect Input' 55 | 56 | 57 | class ArubaDevice(base_device.BaseDevice): 58 | """A base device model suitable for Aruba devices. 59 | 60 | See the base_device.BaseDevice method docstrings. 61 | """ 62 | 63 | def __init__(self, **kwargs): 64 | self.vendor_name = 'aruba' 65 | super(ArubaDevice, self).__init__(**kwargs) 66 | # Aruba prompt sample = '(sydpirwmc1) #'. 67 | self._success = r'(?:^|\n)(\([A-Za-z0-9\.\-]+\)\s[#>])' 68 | 69 | def _Connect(self, username, password=None, ssh_keys=None, 70 | enable_password=None, ssl_cert_set=None): 71 | _ = enable_password, ssl_cert_set 72 | self._connection = pexpect_connection.ParamikoSshConnection( 73 | self.loopback_ipv4, username, password, self._success, 74 | timeout=self.timeout_connect, find_prompt=True, ssh_keys=ssh_keys) 75 | try: 76 | self._connection.Connect() 77 | self._DisablePager() 78 | self.connected = True 79 | except pexpect_connection.ConnectionError as e: 80 | self.connected = False 81 | raise exceptions.ConnectError(e) 82 | except pexpect_connection.TimeoutError as e: 83 | self.connected = False 84 | raise exceptions.ConnectError('Timed out connecting to %s(%s) after ' 85 | '%s seconds.' % 86 | (self.host, self.loopback_ipv4, str(e))) 87 | 88 | def _Cmd(self, command, mode=None): 89 | 90 | def SendAndWait(command): 91 | """Sends a command and waits for a response.""" 92 | self._connection.child.send(command+'\r') 93 | self._connection.child.expect('\r\n', timeout=self.timeout_response) 94 | self._connection.child.expect(self._connection.re_prompt, 95 | timeout=self.timeout_response, 96 | searchwindowsize=128) 97 | return self._connection.child.before.replace('\r\n', os.linesep) 98 | 99 | _ = mode 100 | command = command.replace('?', '') 101 | result = '' 102 | try: 103 | result = SendAndWait(command) 104 | except pexpect.TIMEOUT as e: 105 | self.connected = False 106 | raise exceptions.CmdError('%s: %s' % (e.__class__, str(e))) 107 | except pexpect.EOF: 108 | # Retry once on EOF error, in case we have been idle disconnected. 109 | try: 110 | self.connected = False 111 | self._connection.Connect() 112 | self._DisablePager() 113 | self.connected = True 114 | result = SendAndWait(command) 115 | except pexpect.EOF: 116 | raise exceptions.CmdError('Failed with EOF error twice.') 117 | except pexpect_connection.ConnectionError as e: 118 | raise exceptions.CmdError('Auto-reconnect failed: %s' % e) 119 | except pexpect_connection.TimeoutError as e: 120 | raise exceptions.CmdError('Auto-reconnect timed out: %s' % e) 121 | 122 | # Fix trailing \r to \n (if \n of last \r\n is captured by prompt). 123 | if result and result[-1] == '\r': 124 | result = result[:-1] + '\n' 125 | 126 | if result.endswith(INVALID_OUT1) or result.startswith(INVALID_OUT2): 127 | raise exceptions.CmdError('Command failed: %s' % result) 128 | 129 | return result 130 | 131 | def _Disconnect(self): 132 | if hasattr(self, '_connection'): 133 | try: 134 | self._connection.child.send('exit\r') 135 | # Loose prompt RE as prompt changes after first exit. 136 | self._connection.child.expect(self._success, 137 | timeout=self.timeout_act_user) 138 | self._connection.child.send('exit\r') 139 | self._connection.child.expect(self._connection.exit_list, 140 | timeout=self.timeout_act_user) 141 | self.connected = False 142 | except (pexpect.EOF, pexpect.TIMEOUT) as e: 143 | self.connected = False 144 | raise exceptions.DisconnectError('%s: %s' % (e.__class__, str(e))) 145 | 146 | def _DisablePager(self): 147 | """Disables the paging.""" 148 | try: 149 | self._connection.child.send('no paging\r') 150 | self._connection.child.expect(self._connection.re_prompt, 151 | timeout=self.timeout_connect, 152 | searchwindowsize=128) 153 | except (pexpect.EOF, pexpect.TIMEOUT) as e: 154 | self.connected = False 155 | raise exceptions.ConnectError('%s: %s' % (e.__class__, str(e))) 156 | logging.debug('Disabled paging on aruba device') 157 | -------------------------------------------------------------------------------- /asa.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # 3 | # Copyright 2014 Google Inc. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | """A Cisco ASA devicemodel. 17 | 18 | This module implements a device interface of base_device.py for 19 | most of the herd of variants of Cisco ASA devices. 20 | """ 21 | 22 | import hashlib 23 | import os 24 | import re 25 | import time 26 | 27 | import pexpect 28 | 29 | from absl import flags as gflags 30 | import logging 31 | 32 | import base_device 33 | import pexpect_connection 34 | import push_exceptions as exceptions 35 | 36 | FLAGS = gflags.FLAGS 37 | 38 | gflags.DEFINE_float('asa_timeout_response', None, 39 | 'ASA device response timeout in seconds.') 40 | gflags.DEFINE_float('asa_timeout_connect', None, 41 | 'ASA device connect timeout in seconds.') 42 | gflags.DEFINE_float('asa_timeout_idle', None, 43 | 'ASA device idle timeout in seconds.') 44 | gflags.DEFINE_float('asa_timeout_disconnect', None, 45 | 'ASA device disconnect timeout in seconds.') 46 | gflags.DEFINE_float('asa_timeout_act_user', None, 47 | 'ASA device user activation timeout in seconds.') 48 | 49 | MD5_RE = re.compile(r'verify /md5 \(\S+\)\s+=\s+([A-Fa-f0-9]+)') 50 | # Used in sleep statements for a minor pause. 51 | MINOR_PAUSE = 0.05 52 | 53 | # Some Cisco ways of saying 'access denied' and/or 'invalid command'. 54 | # Due to the way Cisco privilege levels work and since unknown commands 55 | # may be looked up in DNS, any of these could be a response which really 56 | # means 'access denied', or they could mean what they say. 57 | INVALID_1 = "% Invalid input detected at '^' marker.\n\n" 58 | INVALID_2 = ('% Unknown command or computer name, or unable to find computer ' 59 | 'address\n') 60 | INVALID_3 = 'Command authorization failed.\n\n' 61 | INVALID_4 = '% Authorization failed.\n\n' 62 | INVALID_5 = '% Incomplete command.\n\n' 63 | INVALID_6_PREFIX = '% Ambiguous command:' 64 | 65 | 66 | class DeleteFileError(Exception): 67 | """A file was not successfully deleted.""" 68 | 69 | 70 | class AsaDevice(base_device.BaseDevice): 71 | """A device model for devices with ASA-like interfaces.""" 72 | 73 | def __init__(self, **kwargs): 74 | self.vendor_name = 'ios' 75 | super(AsaDevice, self).__init__(**kwargs) 76 | 77 | # The response regexp indicating connection success. 78 | self._success = r'(?:\r)([]A-Za-z0-9\.\-[]+[>#])' 79 | 80 | def _Connect(self, username, password=None, ssh_keys=None, 81 | enable_password=None, ssl_cert_set=None): 82 | _ = ssl_cert_set 83 | self._connection = pexpect_connection.ParamikoSshConnection( 84 | self.loopback_ipv4, username, password, self._success, 85 | timeout=self.timeout_connect, find_prompt=True, 86 | find_prompt_prefix=r'\r', ssh_keys=ssh_keys, 87 | enable_password=enable_password) 88 | try: 89 | self._connection.Connect() 90 | self._DisablePager() 91 | self.connected = True 92 | except pexpect_connection.ConnectionError as e: 93 | self.connected = False 94 | raise exceptions.ConnectError(e) 95 | except pexpect_connection.TimeoutError as e: 96 | self.connected = False 97 | raise exceptions.ConnectError('Timed out connecting to %s(%s) after ' 98 | '%s seconds.' % 99 | (self.host, self.loopback_ipv4, str(e))) 100 | 101 | def _Cmd(self, command, mode=None): 102 | 103 | def SendAndWait(command): 104 | """Sends a command and waits for a response.""" 105 | self._connection.child.send(command+'\r') 106 | self._connection.child.expect('\r\n', timeout=self.timeout_response) 107 | self._connection.child.expect(self._connection.re_prompt, 108 | timeout=self.timeout_response, 109 | searchwindowsize=128) 110 | return self._connection.child.before.replace('\r\n', os.linesep) 111 | 112 | # Quieten pylint. 113 | _ = mode 114 | # We strip question-marks ('?') from the input as they upset the 115 | # buffering for minimal gain (they work only on ASA and not on FTOS). 116 | command = command.replace('?', '') 117 | result = '' 118 | try: 119 | result = SendAndWait(command) 120 | except pexpect.TIMEOUT as e: 121 | self.connected = False 122 | raise exceptions.CmdError('%s: %s' % (e.__class__, str(e))) 123 | except pexpect.EOF: 124 | # Retry once on EOF error, in case we have been idle disconnected. 125 | try: 126 | self.connected = False 127 | self._connection.Connect() 128 | self._DisablePager() 129 | self.connected = True 130 | result = SendAndWait(command) 131 | except pexpect.EOF: 132 | raise exceptions.CmdError('Failed with EOF error twice.') 133 | except pexpect_connection.ConnectionError as e: 134 | raise exceptions.CmdError('Auto-reconnect failed: %s' % e) 135 | except pexpect_connection.TimeoutError as e: 136 | raise exceptions.CmdError('Auto-reconnect timed out: %s' % e) 137 | 138 | # Fix trailing \r to \n (if \n of last \r\n is captured by prompt). 139 | if result and result[-1] == '\r': 140 | result = result[:-1] + '\n' 141 | 142 | if (result.endswith(INVALID_1) or result.endswith(INVALID_2) or 143 | result.endswith(INVALID_3) or result.endswith(INVALID_4) or 144 | result.endswith(INVALID_5) or ( 145 | result.endswith('\n') and 146 | result[result[:-1].rfind('\n') + 1:].startswith( 147 | INVALID_6_PREFIX))): 148 | raise exceptions.CmdError('Command failed: %s' % result) 149 | 150 | return result 151 | 152 | def _SetConfig(self, destination_file, data, canary): 153 | # Canarying is not supported on ASA. 154 | if canary: 155 | raise exceptions.SetConfigCanaryingError('%s devices do not support ' 156 | 'configuration canarying.' % 157 | self.vendor_name) 158 | # We only support copying to 'running-config' or 'startup-config' on ASA. 159 | if destination_file not in ('running-config', 'startup-config'): 160 | raise exceptions.SetConfigError('destination_file argument must be ' 161 | '"running-config" or "startup-config" ' 162 | 'for %s devices.' % self.vendor_name) 163 | # Result object. 164 | result = base_device.SetConfigResult() 165 | 166 | # Get the MD5 sum of the file. 167 | local_digest = hashlib.md5(data).hexdigest() 168 | 169 | try: 170 | # Get the working path from the remote device 171 | remote_path = 'nvram:/' 172 | except exceptions.CmdError as e: 173 | msg = 'Error obtaining working directory: %s' % e 174 | logging.error(msg) 175 | raise exceptions.SetConfigError(msg) 176 | 177 | # Use a random remote file name 178 | remote_tmpfile = '%s/push.%s' % ( 179 | remote_path.rstrip(), os.urandom(8).encode('hex')) 180 | 181 | # Upload the file to the device. 182 | scp = pexpect_connection.ScpPutConnection( 183 | self.loopback_ipv4, 184 | username=self._username, 185 | password=self._password) 186 | try: 187 | scp.Copy(data, remote_tmpfile) 188 | except pexpect_connection.Error as e: 189 | raise exceptions.SetConfigError( 190 | 'Failed to copy configuration to remote device. %s' % str(e)) 191 | 192 | # Get the file size on the router. 193 | try: 194 | # Get the MD5 hexdigest of the file on the remote device. 195 | try: 196 | verify_output = self._Cmd('verify /md5 %s' % remote_tmpfile) 197 | match = MD5_RE.search(verify_output) 198 | if match is not None: 199 | remote_digest = match.group(1) 200 | else: 201 | raise exceptions.SetConfigError( 202 | 'The "verify /md5 " command did not produce ' 203 | 'expected results. It returned: %r' % verify_output) 204 | except exceptions.CmdError as e: 205 | raise exceptions.SetConfigError( 206 | 'The MD5 hash command on the router did not succed. ' 207 | 'The device may not support: "verify /md5 "') 208 | # Verify the local_digest and remote_digest are the same. 209 | if local_digest != remote_digest: 210 | raise exceptions.SetConfigError( 211 | 'File transfer to remote host corrupted. Local digest: %r, ' 212 | 'Remote digest: %r' % (local_digest, remote_digest)) 213 | 214 | # Copy the file from flash to the 215 | # destination(running-config, startup-config). 216 | # Catch errors that may occur during application, and report 217 | # these to the user. 218 | try: 219 | self._connection.child.send( 220 | 'copy %s %s\r' % (remote_tmpfile, destination_file)) 221 | pindex = self._connection.child.expect( 222 | [r'Destination filename \[%s\]\?' % destination_file, 223 | r'%\s*\S*.*', 224 | r'%Error.*', 225 | self._connection.re_prompt], 226 | timeout=self.timeout_act_user) 227 | if pindex == 0: 228 | self._connection.child.send('\r') 229 | try: 230 | pindex = self._connection.child.expect( 231 | [r'Invalid input detected', 232 | self._connection.re_prompt, 233 | r'%Warning:There is a file already existing.*' 234 | 'Do you want to over write\? \[confirm\]'], 235 | timeout=self.timeout_act_user) 236 | if pindex == 0: 237 | # Search again using findall to get all bad lines. 238 | bad_lines = re.findall( 239 | r'^(.*)$[\s\^]+% Invalid input', 240 | self._connection.child.match.string, 241 | re.MULTILINE) 242 | raise exceptions.SetConfigSyntaxError( 243 | 'Configuration loaded, but with bad lines:\n%s' % 244 | '\n'.join(bad_lines)) 245 | if pindex == 2: 246 | # Don't over-write. 247 | self._connection.child.send('n') 248 | raise exceptions.SetConfigError( 249 | 'Destination file %r already exists, cannot overwrite.' 250 | % destination_file) 251 | except (pexpect.EOF, pexpect.TIMEOUT) as e: 252 | raise exceptions.SetConfigError( 253 | 'Copied file to device, but did not ' 254 | 'receive prompt afterwards. %s %s' % 255 | (self._connection.child.before, self._connection.child.after)) 256 | 257 | elif pindex == 2: 258 | print "MATCHED 2" 259 | # The expect does a re.search, search again using findall to get all 260 | raise exceptions.SetConfigError('Could not copy temporary ' 261 | 'file to %s.' % destination_file) 262 | except (pexpect.EOF, pexpect.TIMEOUT) as e: 263 | raise exceptions.SetConfigError( 264 | 'Attempted to copy to bootflash, but a timeout occurred.') 265 | 266 | # We need to 'write memory' if we are doing running-config. 267 | if destination_file == 'running-config': 268 | logging.debug('Attempting to copy running-config to startup-config ' 269 | 'on %s(%s)', self.host, self.loopback_ipv4) 270 | try: 271 | self._Cmd('wr mem') 272 | except exceptions.CmdError as e: 273 | raise exceptions.SetConfigError('Failed to write startup-config ' 274 | 'for %s(%s). Changes applied. ' 275 | 'Error was: %s' % 276 | (self.host, self.loopback_ipv4, 277 | str(e))) 278 | finally: 279 | try: 280 | self._DeleteFile(remote_tmpfile) 281 | except DeleteFileError as e: 282 | result.transcript = 'SetConfig warning: %s' % str(e) 283 | logging.warn(result.transcript) 284 | 285 | # And finally, return the result text. 286 | return result 287 | 288 | def _DeleteFile(self, file_name): 289 | """Delete a file. 290 | 291 | Args: 292 | file_name: A string, the file name. 293 | 294 | Raises: 295 | DeleteFileError, if the deletion failed. 296 | """ 297 | try: 298 | self._connection.child.send('\r') 299 | self._connection.child.expect('\r\n', timeout=self.timeout_act_user) 300 | self._connection.child.expect(self._connection.re_prompt, 301 | timeout=self.timeout_act_user, 302 | searchwindowsize=128) 303 | self._connection.child.send('delete %s\r' % file_name) 304 | except pexpect.ExceptionPexpect: 305 | raise DeleteFileError('DeleteFile operation failed. %s' % 306 | self._connection.child) 307 | 308 | try: 309 | pindex = self._connection.child.expect( 310 | [r'Delete filename \[.*\]\?', 311 | r'%.*Error.*'], 312 | timeout=self.timeout_act_user) 313 | if pindex == 0: 314 | self._connection.child.send('\r') 315 | logging.debug('DeleteFile: answering first confirmation.') 316 | self._connection.child.expect([r'Delete .*\[confirm\]'], 317 | timeout=self.timeout_act_user) 318 | logging.debug('DeleteFile: answering second confirmation.') 319 | self._connection.child.send('\r') 320 | elif pindex == 1: 321 | raise DeleteFileError('DeleteFile operation failed. %s' % 322 | self._connection.child.match) 323 | 324 | pindex = self._connection.child.expect([self._connection.re_prompt, 325 | r'%.*Error.*'], 326 | timeout=self.timeout_act_user) 327 | if pindex == 1: 328 | raise DeleteFileError('DeleteFile operation failed. %s' % 329 | self._connection.child.match) 330 | logging.debug('DeleteFile: success.') 331 | except pexpect.ExceptionPexpect: 332 | raise DeleteFileError('DeleteFile operation failed. %s' % 333 | self._connection.child) 334 | 335 | def _GetConfig(self, source): 336 | try: 337 | if source in ('running-config', 'startup-config'): 338 | result = self._Cmd('show %s' % source) 339 | else: 340 | raise exceptions.GetConfigError('source argument must be ' 341 | '"running-config" or ' 342 | '"startup-config".') 343 | if not result: 344 | return exceptions.EmptyConfigError('%s has an empty configuration.' % 345 | self.host) 346 | else: 347 | return result 348 | except exceptions.Error as e: 349 | raise exceptions.GetConfigError('Could not fetch config from %s. %s.' % 350 | (self.host, str(e))) 351 | 352 | def _Disconnect(self): 353 | if hasattr(self, '_connection'): 354 | try: 355 | self._connection.child.send('exit\r') 356 | self._connection.child.expect(self._connection.exit_list, 357 | timeout=self.timeout_act_user) 358 | self.connected = False 359 | except (pexpect.EOF, pexpect.TIMEOUT) as e: 360 | self.connected = False 361 | raise exceptions.DisconnectError('%s: %s' % (e.__class__, str(e))) 362 | 363 | def _DisablePager(self): 364 | """Disables the pager.""" 365 | try: 366 | self._connection.child.send('\r') 367 | self._connection.child.expect(r'\r\n', 368 | timeout=self.timeout_connect) 369 | self._connection.child.expect(self._connection.re_prompt, 370 | timeout=self.timeout_connect, 371 | searchwindowsize=128) 372 | self._connection.child.send('terminal pager 0\r') 373 | pindex = self._connection.child.expect( 374 | [self._connection.re_prompt, r'Command authorization failed\.'], 375 | timeout=self.timeout_connect) 376 | if pindex == 1: 377 | self.connected = False 378 | raise exceptions.ConnectError('terminal length 0 command denied.') 379 | # Pause momentarily to avoid a TAC+ packet drop. 380 | time.sleep(0.5) 381 | except (pexpect.EOF, pexpect.TIMEOUT) as e: 382 | self.connected = False 383 | raise exceptions.ConnectError('%s: %s' % (e.__class__, str(e))) 384 | logging.debug('terminal length set to 0') 385 | -------------------------------------------------------------------------------- /base_device.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # 3 | # Copyright 2014 Google Inc. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | """An abstract device model. 17 | 18 | Concrete implementations should be placed alongside this. 19 | 20 | Concerete subclasses must implement all methods that have NotImplementedError 21 | exceptions raised in this abstract interface. Methods Lock and Unlock are 22 | optional, so clients of the device classes should expect that a 23 | NotSupportedError will potentially be raised. 24 | """ 25 | 26 | import time 27 | #import gflags 28 | from absl import flags as gflags 29 | import push_exceptions as exceptions 30 | import logging 31 | 32 | 33 | FLAGS = gflags.FLAGS 34 | 35 | gflags.DEFINE_float('host_down_sinbin_time', 36 | 180.0, 37 | 'Seconds that down hosts are placed in the sin-bin for.') 38 | 39 | # Define the default timeout values for each vendor. 40 | # Each vendor also provides the same flags (s/base/$VENDOR_NAME/), 41 | # with None as the default value. See BaseDevice._SetupTimeouts. 42 | gflags.DEFINE_float('base_timeout_response', 43 | 300.0, 44 | 'Default device response timeout in seconds.') 45 | gflags.DEFINE_float('base_timeout_connect', 46 | 10.0, 47 | 'Default device connect timeout in seconds.') 48 | gflags.DEFINE_float('base_timeout_idle', 49 | 600.0, 50 | 'Default device idle timeout in seconds.') 51 | gflags.DEFINE_float('base_timeout_disconnect', 52 | 10.0, 53 | 'Default device disconnect timeout in seconds.') 54 | gflags.DEFINE_float('base_timeout_act_user', 55 | 10.0, 56 | 'Default device user activation timeout in seconds.') 57 | # The default for this is set to 180 seconds, so that it is the same as 58 | # host_down_sinbin_time's default. This effectively disables the faster retries 59 | # by default - the flag must be used to enable them. 60 | gflags.DEFINE_float('base_device_initial_failure_delay', 180.0, 61 | 'If a device fails to connect, retry after ' 62 | 'this many seconds at first, doubling each time ' 63 | 'for frequent errors (only applies to whitelisted devices).') 64 | gflags.DEFINE_float('base_device_failure_forgiveness_delay', 10 * 60, 65 | 'Forget connect failures that happened more than this many ' 66 | 'seconds ago (only on whitelisted devices).') 67 | 68 | 69 | class BaseDevice(object): 70 | """A skeleton base device referring to a specific device in the network. 71 | 72 | Notes: 73 | All methods other than Connect and Nop raise NotImplementedError as 74 | they are pure virtual methods. 75 | 76 | Methods that have arguments perform argument type testing prior to 77 | calling private implementations of their method. Replace the private 78 | method in your implementation. 79 | 80 | Attributes: 81 | host: A string, the host name. 82 | loopback_ipv4: A string representation of the IPv4 address used for 83 | device management inside device modules. 84 | vendor: A string, the vendor, e.g., 'JUNIPER'. 85 | connected: A bool, whether we are connected to the device or not. 86 | active: A bool, whether we're active or not. 87 | rollout: A list of strings, active rollout tags for the device. 88 | """ 89 | # A dict to map from vendor string to vendor class, e.g., 90 | # {'FORCE10': ftos.FtosDevice} 91 | # This dict is updated by each concrete subclass at class load time (by 92 | # factory.py). 93 | vendor_classes = {} 94 | 95 | # Standardized strings defining types of configurations. 96 | CONFIG_RUNNING = 'running-config' 97 | CONFIG_STARTUP = 'startup-config' 98 | CONFIG_PATCH = 'patch-config' 99 | NON_FILE_DESTINATIONS = (CONFIG_RUNNING, CONFIG_STARTUP, CONFIG_PATCH) 100 | 101 | def __init__(self, **kwargs): 102 | # Use kwargs so that subclasses can extend this state via the factory. 103 | self.host = kwargs.get('host', None) 104 | self.loopback_ipv4 = kwargs.get('loopback_ipv4', None) 105 | self.accessproxy = kwargs.get('accessproxy', None) 106 | self.accessproxy_device_dict = {} 107 | self.role = kwargs.get('role', None) 108 | self.realm = kwargs.get('realm', None) 109 | self.notes = self.__class__.__name__ 110 | # Default to true for active. 111 | self.active = kwargs.get('active', True) 112 | self.vendor = kwargs.get('vendor', None) 113 | self.rollout = kwargs.get('rollout', []) 114 | self._subclass = kwargs.get('subclass', False) 115 | # Connection details. 116 | self._username = kwargs.get('username', None) 117 | self._password = None 118 | self._ssh_keys = None 119 | self._enable_password = None 120 | self._ssl_cert_set = None 121 | # Boolean attribute containing the considered state of the device. (True=up) 122 | self._host_status = True 123 | # The time the host's up/down status changed. If None, ignore this value. 124 | self._host_last_status_change = None 125 | # Connected boolean, accessed via property connected. 126 | self._connected = False 127 | 128 | # Our last-raised exception if not None. 129 | self.__exc = None 130 | # If we have been initialised directly, set our vendor name. 131 | if not hasattr(self, 'vendor_name'): 132 | self.vendor_name = 'base' 133 | # Some sub-classes override this. 134 | if not hasattr(self, 'unsupported_non_file_destinations'): 135 | self.unsupported_non_file_destinations = (self.CONFIG_PATCH,) 136 | # Setup timeouts. 137 | self._InitialiseTimeouts() 138 | 139 | def __del__(self): 140 | """Special delete method called on object garbage collection. 141 | 142 | Holders of device objects should call Disconnect() explicltly, 143 | rather than relying on disconnection by this method. 144 | 145 | A global Exception handler must ensure deletion of references to 146 | instances of this class. Garbage collection will close device 147 | connections when it runs this method, but there are no guarantees it 148 | will be run for all classes at program exit. 149 | """ 150 | if self.connected: 151 | logging.debug('Garbage collection disconnecting %r' % self.host) 152 | self.Disconnect() 153 | 154 | def __str__(self): 155 | return '%s(host=%s, vendor=%s, role=%s)' % ( 156 | self.__class__.__name__, 157 | repr(self.host), 158 | repr(self.vendor), 159 | repr(self.role)) 160 | 161 | def _InitialiseTimeouts(self): 162 | """Sets up timeouts by scanning module flags. 163 | 164 | Subclasses must provide a _SetTimeouts method, to be called at the 165 | end of initialization. 166 | """ 167 | for var in ('connect', 'response', 'idle', 'disconnect', 'act_user'): 168 | flag_name = '%s_timeout_%s' % (self.vendor_name, var) 169 | default_flag_name = 'base_timeout_%s' % var 170 | 171 | if getattr(FLAGS, flag_name) is not None: 172 | value = getattr(FLAGS, flag_name) 173 | setattr(self, 'timeout_%s' % var, value) 174 | else: 175 | default_value = getattr(FLAGS, default_flag_name) 176 | setattr(self, 'timeout_%s' % var, default_value) 177 | # Allow devices to optionally override timeouts. 178 | self._SetupTimeouts() 179 | 180 | def _SetupTimeouts(self): 181 | """Optionally setup device specific timeout value logic. 182 | 183 | If more than a global and device module specific timeout value are 184 | required (e.g., to set a minima), implement this method in the 185 | concrete device module. It need not be provided otherwise. 186 | """ 187 | pass 188 | 189 | def _HostDownPrepareConnect(self): 190 | """Works out if it's safe to retry a connection attempt. 191 | 192 | Raises an exception if we're not prepared to retry the connection attempt. 193 | See also Connect, and HandleConnectFailure. 194 | 195 | Raises: 196 | The last exception class recorded in self.__exc. 197 | """ 198 | now = time.time() 199 | time_left = self._dampen_end_time - now 200 | logging.debug('BaseDevice.Connect is waiting because of previous ' 201 | 'connection errors, host is %s, time_left is %s', 202 | self.host, time_left) 203 | if time_left > 0: 204 | # pylint: disable=g-doc-exception 205 | raise self.__exc.__class__( 206 | 'Connection to %s(%s) failed. Will not retry for %.1fs.' 207 | % (self.host, self.loopback_ipv4, time_left), 208 | dampen_connect=True) 209 | # pylint: enable=g-doc-exception 210 | else: 211 | # Next time, we'll try to connect. 212 | self._host_status = True 213 | self.connected = False 214 | 215 | def Connect(self, username, password=None, ssh_keys=None, 216 | enable_password=None, ssl_cert_set=None): 217 | """Sets up a connection to the device. 218 | 219 | Concrete classes must implement _Connect() instead, with the same arguments. 220 | 221 | Concrete classes are expected not to disconnect the connection until it 222 | is cleaned-up by Disconnect(). A generic exception handler at the top- 223 | level should ensure sessions have an opportunity to be cleaned-up upon 224 | abnormal program termination. 225 | 226 | Args: 227 | username: A string, the username (role account) to use. 228 | password: A string, the password to use (optional; may be None). 229 | ssh_keys: A tuple of strings, SSH private keys (optional; may be None). 230 | enable_password: A string, an optional enable password (may be None). 231 | ssl_cert_set: An optional SSLCertificateSet protobuf (may be None). 232 | 233 | Raises: 234 | exceptions.ConnectError: the connection could not be established. 235 | exceptions.AuthenticationError: A device authentication error occurred, or 236 | neither a password nor an SSH private key was supplied. 237 | """ 238 | # Either an SSH key or password must be supplied for authentication. 239 | if (password is None and not 240 | ssh_keys and not 241 | ssl_cert_set and not 242 | FLAGS.use_ssh_agent): 243 | raise exceptions.AuthenticationError( 244 | 'Cannot connect. No authentication information provided to device ' 245 | 'Connect method.') 246 | 247 | self._username = username 248 | self._password = password 249 | self._ssh_keys = ssh_keys or () 250 | self._enable_password = enable_password 251 | self._ssl_cert_set = ssl_cert_set 252 | 253 | if not self.loopback_ipv4 and not self.accessproxy_device_dict: 254 | raise exceptions.ConnectError( 255 | 'Device %r, or any access proxies, need to have an IPv4 ' 256 | 'management address.' 257 | % self.host) 258 | 259 | logging.debug('In BaseDevice.Connect, host is %s, _connected is %s', 260 | self.host, self._connected) 261 | while not self.connected: 262 | try: 263 | if self._host_status: 264 | logging.debug('CONNECTING %s(%s)', 265 | self.host, self.loopback_ipv4) 266 | self._Connect(username, password=password, ssh_keys=self._ssh_keys, 267 | enable_password=enable_password, 268 | ssl_cert_set=ssl_cert_set) 269 | self.connected = True 270 | logging.debug('CONNECTED %s(%s)', 271 | self.host, self.loopback_ipv4) 272 | self._last_failure_time = None 273 | else: 274 | self._HostDownPrepareConnect() 275 | except (exceptions.ConnectError, 276 | exceptions.AuthenticationError), e: 277 | logging.error('CONNECT FAILURE %s(%s)', 278 | self.host, self.loopback_ipv4) 279 | self._host_status = False 280 | self.__exc = e 281 | raise 282 | logging.debug('Leaving BaseDevice.Connect, host is %s, _connected is %s', 283 | self.host, self._connected) 284 | return None 285 | 286 | def Nop(self, name): 287 | """No-operation. 288 | 289 | Args: 290 | name: A string, the (no) operation's name. 291 | 292 | Returns: 293 | A string, some output (can be ignored by the client). 294 | """ 295 | msg = 'No-operation request named `%s` received.' % name 296 | logging.debug('ActionRequest: NOP %s %s', str(self.__class__), repr(msg)) 297 | return msg 298 | 299 | def Cmd(self, command, mode=None): 300 | """Executes a command. 301 | 302 | Concrete classes must define _Cmd with the same arguments. 303 | 304 | Args: 305 | command: A string, the command to execute. 306 | mode: A string, the CLI mode to use for this command (e.g., 'shell' 307 | on Netscaler). The empty string or None will use the device's 308 | default mode. 309 | 310 | Returns: 311 | A string, the response. 312 | 313 | Raises: 314 | exceptions.CmdError: An error occurred inside the call to _Cmd. 315 | """ 316 | if not command: 317 | raise exceptions.CmdError('No command supplied for Cmd() method.') 318 | else: 319 | if not mode: 320 | mode = None 321 | return self._Cmd(command, mode=mode) 322 | 323 | def GetConfig(self, source): 324 | """Returns a configuration file from the device. 325 | 326 | Concrete classes must define _GetConfig with the same arguments. 327 | 328 | Args: 329 | source: A string, representing either a path to a configuration file or a 330 | string to be interpreted by the device module. For readability, 331 | consider using CONFIG_RUNNING and CONFIG_STARTUP to represent the 332 | generic concepts of the running and startup configurations. 333 | 334 | Returns: 335 | A string, the configuration file. (This may be large). 336 | 337 | Raises: 338 | GetConfigError: the GetConfig operation failed. 339 | EmptyConfigError: the operation produced an empty configuration. 340 | """ 341 | return self._GetConfig(source) 342 | 343 | def SetConfig(self, destination_file, data, canary, 344 | juniper_skip_show_compare=False, 345 | juniper_skip_commit_check=False, 346 | juniper_get_rollback_patch=False): 347 | """Updates a devices' configuration. 348 | 349 | Concrete classes must define _SetConfig with the same arguments. 350 | 351 | Args: 352 | destination_file: A string. A path to a file on the device. 353 | data: A string, the configuration data to set. 354 | canary: A boolean, whether to canary, rather than set, the configuration. 355 | juniper_skip_show_compare: A boolean, temporary flag to skip 356 | 'show | compare' on Junipers due to a bug. 357 | juniper_skip_commit_check: A boolean, flag to skip 'commit check' on 358 | Junipers when doing a canary. 359 | juniper_get_rollback_patch: A boolean, optionally try to retrieve a 360 | patch to rollback the config change. 361 | 362 | Returns: 363 | A SetConfigResult. Transcript of any device interaction that occurred 364 | during the operation, plus any optional extras. 365 | 366 | Raises: 367 | exceptions.SetConfigError: the SetConfig operation failed. 368 | exceptions.SetConfigSyntaxError: the configuration data had a syntax 369 | error. 370 | """ 371 | if destination_file in self.unsupported_non_file_destinations: 372 | raise exceptions.SetConfigError( 373 | '%s devices do not support %s as a destination.' % 374 | (self.vendor_name, destination_file)) 375 | if ((juniper_skip_show_compare or 376 | juniper_skip_commit_check or 377 | juniper_get_rollback_patch) and 378 | self.__class__.__name__ == 'JunosDevice'): 379 | return self._SetConfig(destination_file, data, canary, 380 | skip_show_compare=juniper_skip_show_compare, 381 | skip_commit_check=juniper_skip_commit_check, 382 | get_rollback_patch=juniper_get_rollback_patch) 383 | else: 384 | return self._SetConfig(destination_file, data, canary) 385 | 386 | def Disconnect(self): 387 | """Disconnects from the device. 388 | 389 | Concrete classes must define _Disconnect. 390 | 391 | This method is called by the class __del__ method, and should also be 392 | called by any global Exception handler (as __del__() is not guaranteed to 393 | be called when the Python interpreter exits). 394 | 395 | Disconnect is also called by the Device Manager during garbage collection. 396 | 397 | Raises: 398 | exceptions.DisconnectError if the disconnect operation failed. 399 | """ 400 | self._Disconnect() 401 | self.connected = False 402 | logging.debug('DISCONNECTED %s(%s)', 403 | self.host, self.loopback_ipv4) 404 | 405 | def _GetConnected(self): 406 | return self._connected 407 | 408 | def _SetConnected(self, c): 409 | logging.debug('Setting connected property on host %s to %s', 410 | self.host, c) 411 | self._connected = c 412 | 413 | # Property for the connection status. 414 | connected = property(_GetConnected, _SetConnected) 415 | 416 | 417 | class SetConfigResult(object): 418 | """Results of one SetConfig, including transcript and any optional extras. 419 | 420 | Attributes: 421 | transcript: A string, the chatter from the router and/or any error text. 422 | rollback_patch: None or a string, the optional rollback patch, if supported. 423 | """ 424 | 425 | def __init__(self): 426 | self.transcript = '' 427 | self.rollback_patch = None 428 | 429 | def __len__(self): 430 | return len(self.transcript) + len(self.rollback_patch or '') 431 | -------------------------------------------------------------------------------- /brocade.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # 3 | # Copyright 2014 Google Inc. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | """Brocade device implementation. 17 | 18 | This module implements the base device interface of base_device.py for 19 | several Brocade models; MLX, TurboIron and FastIron. 20 | """ 21 | 22 | __author__ = 'weisu@google.com (Wei Su)' 23 | 24 | import os 25 | import re 26 | import string 27 | import time 28 | 29 | import pexpect 30 | 31 | from absl import flags as gflags 32 | import logging 33 | 34 | import base_device 35 | import pexpect_connection 36 | import push_exceptions as exceptions 37 | 38 | 39 | FLAGS = gflags.FLAGS 40 | 41 | gflags.DEFINE_float('brocademlx_timeout_response', None, 42 | 'Brocade device response timeout in seconds.') 43 | gflags.DEFINE_float('brocademlx_timeout_connect', None, 44 | 'Brocade device connect timeout in seconds.') 45 | gflags.DEFINE_float('brocademlx_timeout_idle', None, 46 | 'Brocade device idle timeout in seconds.') 47 | gflags.DEFINE_float('brocademlx_timeout_disconnect', None, 48 | 'Brocade device disconnect timeout in seconds.') 49 | gflags.DEFINE_float('brocademlx_timeout_act_user', None, 50 | 'Brocade device user activation timeout in seconds.') 51 | gflags.DEFINE_float('brocadefi_timeout_response', None, 52 | 'Brocade FastIron device response timeout in seconds.') 53 | gflags.DEFINE_float('brocadefi_timeout_connect', None, 54 | 'Brocade FastIron device connect timeout in seconds.') 55 | gflags.DEFINE_float('brocadefi_timeout_idle', None, 56 | 'Brocade FastIron device idle timeout in seconds.') 57 | gflags.DEFINE_float('brocadefi_timeout_disconnect', None, 58 | 'Brocade FastIron device disconnect timeout in seconds.') 59 | gflags.DEFINE_float('brocadefi_timeout_act_user', None, 60 | 'Brocade FastIron device user activation timeout in' 61 | 'seconds.') 62 | gflags.DEFINE_float('brocadeti_timeout_response', None, 63 | 'Brocade TurboIron device response timeout in seconds.') 64 | gflags.DEFINE_float('brocadeti_timeout_connect', None, 65 | 'Brocade TurboIron device connect timeout in seconds.') 66 | gflags.DEFINE_float('brocadeti_timeout_idle', None, 67 | 'Brocade TurboIron device idle timeout in seconds.') 68 | gflags.DEFINE_float('brocadeti_timeout_disconnect', None, 69 | 'Brocade TurboIron device disconnect timeout in seconds.') 70 | gflags.DEFINE_float('brocadeti_timeout_act_user', None, 71 | 'Brocade TurboIron device user activation timeout in' 72 | 'seconds.') 73 | 74 | # Used in sleep statements for a minor pause. 75 | MINOR_PAUSE = 0.05 76 | 77 | RE_FILE_LISTING = re.compile( 78 | r'^[\d\/]+' # Leading whitespace, then the file number. 79 | r'\s+' # Whitespace. 80 | r'[\d\:]+' # Hour:minute:seconds. 81 | r'\s+' 82 | r'([\d\,]+)' # File size in bytes. 83 | r'\s+' 84 | r'(.*)') # File name. 85 | 86 | _BROCADE_TIFI_DISABLE_PAGER = 'skip-page-display\r' 87 | _BROCADE_MLX_DISABLE_PAGER = 'terminal length 0\r' 88 | 89 | class BrocadeDevice(base_device.BaseDevice): 90 | """A common superclass for Brocade devices.""" 91 | 92 | verboten_commands = ( 93 | 'monitor ', 94 | 'terminal length ', 95 | 'terminal monitor', 96 | 'page-display', 97 | 'quit', 98 | 'exit', 99 | ) 100 | 101 | verboten_config = ('quit',) 102 | disable_pager_command = '' 103 | 104 | def __init__(self, **kwargs): 105 | self.ssh_client = kwargs.pop('ssh_client', None) 106 | super(BrocadeDevice, self).__init__(**kwargs) 107 | self._success = r'(?:^|\n)([A-Za-z0-9@\.\-]+[>#])' 108 | 109 | def _Connect(self, username=None, password=None, ssh_keys=None, 110 | enable_password=None, ssl_cert_set=None): 111 | _ = enable_password, ssl_cert_set 112 | self._connection = pexpect_connection.ParamikoSshConnection( 113 | self.loopback_ipv4, username, password, self._success, 114 | timeout=self.timeout_connect, find_prompt=True, ssh_keys=ssh_keys, 115 | # Brocade case 1101014 - \n\r\0 newlines in some 'tm voq' outputs. 116 | ssh_client=self.ssh_client, find_prompt_prefix=r'(?:^|\n|\n\r\0)') 117 | try: 118 | self._connection.Connect() 119 | self._DisablePager() 120 | self.connected = True 121 | except pexpect_connection.ConnectionError, e: 122 | self.connected = False 123 | raise exceptions.ConnectError(e) 124 | except pexpect_connection.TimeoutError, e: 125 | self.connected = False 126 | raise exceptions.ConnectError('Timed out connecting to %s(%s) after ' 127 | '%s seconds.' % 128 | (self.host, self.loopback_ipv4, str(e))) 129 | 130 | def _Cmd(self, command, mode=None): 131 | 132 | def SendAndWait(command): 133 | """Sends a command and waits for a response.""" 134 | self._connection.child.send(command+'\r') 135 | self._connection.child.expect('\r\n', timeout=self.timeout_response) 136 | self._connection.child.expect(self._connection.re_prompt, 137 | timeout=self.timeout_response, 138 | searchwindowsize=128) 139 | return self._connection.child.before.replace('\r\n', os.linesep) 140 | 141 | _ = mode 142 | command = command.replace('?', '') 143 | if next((command 144 | for prefix in self.verboten_commands 145 | if command.startswith(prefix)), False): 146 | raise exceptions.CmdError( 147 | 'Command %s is not permitted on Brocade devices.' % command) 148 | result = '' 149 | try: 150 | result = SendAndWait(command) 151 | except pexpect.TIMEOUT, e: 152 | self.connected = False 153 | raise exceptions.CmdError('%s: %s' % (e.__class__, str(e))) 154 | except pexpect.EOF: 155 | # Retry once on EOF error, in case we have been idle disconnected. 156 | try: 157 | self.connected = False 158 | self._connection.Connect() 159 | self._DisablePager() 160 | self.connected = True 161 | result = SendAndWait(command) 162 | except pexpect.EOF: 163 | raise exceptions.CmdError('Failed with EOF error twice.') 164 | except pexpect_connection.ConnectionError, e: 165 | raise exceptions.CmdError('Auto-reconnect failed: %s' % e) 166 | except pexpect_connection.TimeoutError, e: 167 | raise exceptions.CmdError('Auto-reconnect timed out: %s' % e) 168 | 169 | # Fix trailing \r to \n (if \n of last \r\n is captured by prompt). 170 | if result and result[-1] == '\r': 171 | result = result[:-1] + '\n' 172 | 173 | if (result.startswith('Invalid input -> ') or 174 | result == 'Not authorized to execute this command.\n'): 175 | if result.endswith('\nType ? for a list\n'): 176 | result = result[:-19] 177 | elif result.endswith('\n'): 178 | result = result[:-1] 179 | raise exceptions.CmdError(result) 180 | return result 181 | 182 | # Details about per-line necessity for FI/TI can be found at go/brocadepush. 183 | def _SetConfig(self, unused_destination_file, data, canary): 184 | """Upload config to a Brocade router (TI/FI). 185 | 186 | Args: 187 | unused_destination_file: Unused. 188 | data: A string, the data to copy to destination_file. 189 | canary: A boolean, if True, only canary check the configuration, don't 190 | apply it. 191 | 192 | Returns: 193 | A base_device.SetConfigResult. 194 | Transcript of any device interaction that occurred during the _SetConfig. 195 | 196 | Raises: 197 | exceptions.CmdError: An error occurred inside the call to _Cmd. 198 | """ 199 | # Canarying is not supported on BROCADE. 200 | if canary: 201 | raise exceptions.SetConfigCanaryingError('%s devices do not support ' 202 | 'configuration canarying.' % 203 | self.vendor_name) 204 | # The result object. 205 | result = base_device.SetConfigResult() 206 | # Check for a connection to the Brocade. 207 | if not self._GetConnected(): 208 | raise exceptions.SetConfigError('Cannot use unless already ' 209 | 'connected to the device.') 210 | 211 | # Derive our config prompt from the discovered prompt. 212 | self._connection.config_prompt = re.compile( 213 | self._connection.re_prompt.pattern[:-2] + r'\(config\S*\)' 214 | + self._connection.re_prompt.pattern[-2:]) 215 | 216 | # Enter config mode. 217 | self._connection.child.send('configure terminal\r') 218 | self._connection.child.expect('\r\n', timeout=self.timeout_response) 219 | self._connection.child.expect(self._connection.config_prompt, 220 | timeout=self.timeout_response, 221 | searchwindowsize=128) 222 | def SendAndWait(command): 223 | """Sends a command and waits for a response. 224 | 225 | Args: 226 | command: str; A single config line. 227 | 228 | Returns: 229 | A string; the last response. 230 | 231 | Raises: 232 | exceptions.SetConfigError: When we unexpectedly exit configuration mode 233 | while setting config. 234 | """ 235 | self._connection.child.send(command+'\r') 236 | self._connection.child.expect('\r\n', timeout=self.timeout_response) 237 | pindex = self._connection.child.expect( 238 | [self._connection.config_prompt, self._connection.re_prompt], 239 | timeout=self.timeout_response, searchwindowsize=128) 240 | # We unexpectedly exited config mode. Too many exits or ctrl-z. 241 | if pindex == 1: 242 | raise exceptions.SetConfigError( 243 | 'Unexpectedly exited config mode after line: %s' % command) 244 | return self._connection.child.before.replace('\r\n', os.linesep) 245 | 246 | lines = [x.strip() for x in data.splitlines()] 247 | # Remove any 'end' lines. Multiple ends could be bad. 248 | lines = [line for line in lines if line != 'end'] 249 | 250 | for line in lines: 251 | if next((line 252 | for prefix in self.verboten_config 253 | if line.startswith(prefix)), False): 254 | raise exceptions.CmdError( 255 | 'Command %s is not permitted on Brocade devices.' % line) 256 | if line: 257 | line_result = SendAndWait(line) 258 | if (line_result.startswith('Invalid input -> ') or 259 | line_result == 'Not authorized to execute this command.\n'): 260 | raise exceptions.CmdError('Command failed: %s' % line_result) 261 | 262 | self._connection.child.send('end\r') 263 | self._connection.child.expect(self._connection.re_prompt, 264 | timeout=self.timeout_act_user) 265 | self._connection.child.send('wr mem\r') 266 | self._connection.child.expect(self._connection.re_prompt, 267 | timeout=self.timeout_act_user) 268 | self._Disconnect() 269 | result.transcript = 'SetConfig applied the file successfully.' 270 | return result 271 | 272 | def _GetConnected(self): 273 | """Returns the connected state.""" 274 | if not (hasattr(self, '_connection') and 275 | hasattr(self._connection, 'child')): 276 | # The connection has disappeared. 277 | self.connected = False 278 | else: 279 | # Are we still connected? 280 | try: 281 | self.connected = not bool(self._connection.child.flag_eof) 282 | except (AttributeError, TypeError): 283 | # The connection has (just) disappeared. 284 | self.connected = False 285 | return self.connected 286 | 287 | def _Disconnect(self): 288 | if hasattr(self, '_connection'): 289 | try: 290 | self._connection.child.send('exit\r') 291 | # Loose prompt RE as prompt changes after first exit. 292 | self._connection.child.expect(self._success, 293 | timeout=self.timeout_act_user) 294 | self._connection.child.send('exit\r') 295 | self._connection.child.expect(self._connection.exit_list, 296 | timeout=self.timeout_act_user) 297 | self.connected = False 298 | # EOF is normal for a disconnect. Skip DisconnectError. 299 | except (pexpect.EOF), e: 300 | self.connected = False 301 | except (pexpect.TIMEOUT), e: 302 | self.connected = False 303 | raise exceptions.DisconnectError('%s: %s' % (e.__class__, str(e))) 304 | 305 | def _DisablePager(self): 306 | """Disables the pager.""" 307 | try: 308 | self._connection.child.send(self.disable_pager_command) 309 | self._connection.child.expect(self._connection.re_prompt, 310 | timeout=self.timeout_connect, 311 | searchwindowsize=128) 312 | except (pexpect.EOF, pexpect.TIMEOUT), e: 313 | self.connected = False 314 | raise exceptions.CmdError('%s: %s' % (e.__class__, str(e))) 315 | 316 | 317 | class BrocadeMlxDevice(BrocadeDevice): 318 | """A base device model suitable for Brocade MLX devices. 319 | 320 | See the base_device.BaseDevice method docstrings. 321 | """ 322 | 323 | disable_pager_command = _BROCADE_MLX_DISABLE_PAGER 324 | 325 | def __init__(self, **kwargs): 326 | self.vendor_name = 'brocademlx' 327 | super(BrocadeMlxDevice, self).__init__(**kwargs) 328 | 329 | def _GetFileSize(self, file_name, data): 330 | """Gets the size of a file in Brocade 'dir' output. 331 | 332 | Args: 333 | file_name: A string, the file name. 334 | data: A string, the Brocade's "dir" output. 335 | 336 | Returns: 337 | An int, the file size, or None if the value could not be determined. 338 | """ 339 | for line in data.splitlines(): 340 | match = RE_FILE_LISTING.match(line) 341 | if match is not None: 342 | (file_size, fname) = match.groups() 343 | for char in string.punctuation: 344 | file_size = file_size.replace(char, '') 345 | if file_name.strip() == fname.strip(): 346 | try: 347 | return int(file_size) 348 | except ValueError: 349 | continue 350 | return None 351 | 352 | def _SetConfig(self, destination_file, data, canary): 353 | # Canarying is not supported on BROCADE. 354 | if canary: 355 | raise exceptions.SetConfigCanaryingError('%s devices do not support ' 356 | 'configuration canarying.' % 357 | self.vendor_name) 358 | # The result object. 359 | result = base_device.SetConfigResult() 360 | # Check for a connection to the Brocade. 361 | if not self._GetConnected(): 362 | raise exceptions.SetConfigError('Cannot use unless already ' 363 | 'connected to the device.') 364 | 365 | if destination_file in self.NON_FILE_DESTINATIONS: 366 | # Use a random remote file name 367 | file_name = 'push.%s' % os.urandom(8).encode('hex') 368 | else: 369 | # Okay, the user is just copying a file, not a configuraiton into either 370 | # startup-config or running-config, therefore we should use the entire 371 | # path. 372 | file_name = destination_file 373 | 374 | # Copy the file to the router using SCP. 375 | scp = pexpect_connection.ScpPutConnection( 376 | host=self.loopback_ipv4, 377 | username=self._username, 378 | password=self._password) 379 | 380 | # This is a workaround. Brocade case: 537017. 381 | # Brocade changed all the filename to lowercases after scp 382 | file_name = file_name.lower() 383 | try: 384 | scp.Copy(data, destination_file='slot1:' + file_name) 385 | except pexpect_connection.Error, e: 386 | raise exceptions.SetConfigError( 387 | 'Failed to copy configuration to remote device. %s' % str(e)) 388 | # Now that everything is OK locally and the file has been copied, 389 | # check the file and tell the device to set the new configuration. 390 | try: 391 | # Get the file size on the Brocade. 392 | try: 393 | cmd = 'dir /slot1/%s' % file_name 394 | dir_output = self._Cmd(cmd) 395 | except exceptions.CmdError, e: 396 | if 'Invalid input at' in str(e): 397 | raise exceptions.AuthenticationError( 398 | 'Username/password for %s(%s) has insufficient privileges ' 399 | 'to set configuration.' % 400 | (self.host, self.loopback_ipv4)) 401 | else: 402 | raise exceptions.SetConfigError('Could not traverse directory ' 403 | 'output. Command was: %r. ' 404 | 'Error: %r' % (cmd, str(e))) 405 | destination_file_size = self._GetFileSize(file_name, dir_output) 406 | # We couldn't parse the output for some reason. 407 | if destination_file_size is None: 408 | raise exceptions.SetConfigError('Could not find or parse remote ' 409 | 'file size after copy to device.') 410 | 411 | # Verify file is the correct size on the Brocade. 412 | # This should use a checksum (e.g. MD5 or SHA1); Brocade case: 609719. 413 | if destination_file_size != len(data): 414 | raise exceptions.SetConfigError( 415 | 'File transfer corrupted. Source file was: %d bytes, ' 416 | 'Destination file was: %d bytes.' % 417 | (len(data), destination_file_size)) 418 | 419 | # Copy the file from flash to the 420 | # destination(running-config, startup-config) 421 | if destination_file == self.CONFIG_STARTUP: 422 | try: 423 | self._connection.child.send( 424 | 'copy slot1 startup-config %s\r' % file_name) 425 | time.sleep(MINOR_PAUSE) 426 | pindex = self._connection.child.expect( 427 | ['Total bytes', self._connection.re_prompt, 'Error'], 428 | timeout=self.timeout_act_user) 429 | if pindex == 2: 430 | raise exceptions.SetConfigError('Could not copy temporary ' 431 | 'file to startup-config.') 432 | except (pexpect.EOF, pexpect.TIMEOUT), e: 433 | raise exceptions.SetConfigError(str(e)) 434 | elif destination_file == self.CONFIG_RUNNING: 435 | try: 436 | # This is not working, unfortunately. Cannot copy a file to a running 437 | # config, raised support case RFE2901 438 | self._Cmd('copy slot1 running-config %s' % file_name) 439 | except exceptions.CmdError, e: 440 | raise exceptions.SetConfigError(str(e)) 441 | # We need to 'write memory' if we are doing running-config. 442 | logging.vlog(3, 'Attempting to copy running-config to startup-config ' 443 | 'on %s(%s)', self.host, self.loopback_ipv4) 444 | try: 445 | self._Cmd('wr mem') 446 | except exceptions.CmdError, e: 447 | raise exceptions.SetConfigError('Failed to write startup-config ' 448 | 'for %s(%s). Error was: %s' % 449 | (self.host, self.loopback_ipv4, 450 | str(e))) 451 | 452 | finally: 453 | # Now remove the remote temporary file. 454 | # If this fails, we may have already copied the file, so log warnings 455 | # regarding this and return this information to the user in the 456 | # RPC response, so that they can delete the files. 457 | if destination_file in self.NON_FILE_DESTINATIONS: 458 | try: 459 | self._connection.child.send('delete /slot1/%s\r' % file_name) 460 | pindex = self._connection.child.expect( 461 | ['/slot1/%s removed' % file_name, 462 | 'Remove file /slot1/%s failed - File not found' % file_name, 463 | r'Error: .*'], 464 | timeout=self.timeout_act_user) 465 | if pindex == 0: 466 | self._connection.child.expect(self._connection.re_prompt, 467 | timeout=self.timeout_act_user, 468 | searchwindowsize=128) 469 | elif pindex == 1: 470 | result.transcript = ('Could not delete temporary file %r ' 471 | '(file does not exist). ' % file_name) 472 | logging.warn(result.transcript) 473 | else: 474 | result.transcript = ('Unable to delete temporary file %r. Error: %s' 475 | % (file_name, str(self._connection.child))) 476 | logging.warn(result.transcript) 477 | except (pexpect.EOF, pexpect.TIMEOUT), e: 478 | result.transcript = ('Unable to delete temporary file %r. Error: %s' 479 | % (file_name, str(self._connection.child))) 480 | logging.warn(result.transcript) 481 | 482 | else: 483 | result.transcript = 'SetConfig uploaded the file successfully.' 484 | 485 | return result 486 | 487 | def _GetConfig(self, source): 488 | try: 489 | if source == 'running-config': 490 | result = self._Cmd('show %s' % source) 491 | elif source == 'startup-config': 492 | result = self._Cmd('show configuration') 493 | else: 494 | raise exceptions.GetConfigError('source argument must be ' 495 | '"running-config" or ' 496 | '"startup-config".') 497 | if not result: 498 | return exceptions.EmptyConfigError('%s has an empty configuration.' 499 | % self.host) 500 | else: 501 | return result 502 | except exceptions.Error, e: 503 | raise exceptions.GetConfigError('Could not fetch config from %s. %s.' 504 | % (self.host, str(e))) 505 | 506 | 507 | class BrocadeFiDevice(BrocadeDevice): 508 | """A base device model suitable for Brocade FastIron devices.""" 509 | 510 | disable_pager_command = _BROCADE_TIFI_DISABLE_PAGER 511 | 512 | def __init__(self, **kwargs): 513 | self.vendor_name = 'brocadefi' 514 | super(BrocadeFiDevice, self).__init__(**kwargs) 515 | 516 | 517 | class BrocadeTiDevice(BrocadeDevice): 518 | """A base device model suitable for Brocade TurboIron devices.""" 519 | 520 | disable_pager_command = _BROCADE_TIFI_DISABLE_PAGER 521 | 522 | def __init__(self, **kwargs): 523 | self.vendor_name = 'brocadeti' 524 | super(BrocadeTiDevice, self).__init__(**kwargs) 525 | -------------------------------------------------------------------------------- /brocade_test.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # 3 | # Copyright 2014 Google Inc. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | """Tests for brocade.""" 17 | 18 | import unittest 19 | import brocade 20 | import fake_ssh_connection 21 | import push_exceptions as exceptions 22 | 23 | 24 | class BrocadeMlxDeviceTest(unittest.TestCase): 25 | 26 | def setUp(self): 27 | self.device = brocade.BrocadeMlxDevice(host='bx01.sql01') 28 | 29 | def testCmd(self): 30 | def DoCmd(): 31 | self.device.Cmd('show interfaces') 32 | self.assertRaises(AttributeError, DoCmd) 33 | 34 | def testGetConfig(self): 35 | def DoGetConfig(): 36 | self.device.GetConfig('running-config') 37 | self.assertRaises(AttributeError, DoGetConfig) 38 | 39 | def testDisconnect(self): 40 | self.assertIsNone(self.device._Disconnect()) 41 | 42 | 43 | class BrocadeTiDeviceTest(unittest.TestCase): 44 | 45 | def setUp(self): 46 | cli_prompt = 'SSH@cdzncsa1switch#' 47 | configure_prompt = 'SSH@cdzncsa1switch(config)#\r\n' 48 | config_snippet = """\r 49 | Current configuration:\r 50 | !\r 51 | ver 04.2.00d 52 | !\r 53 | interface ethernet 17\r 54 | port-name cs01.cd.xe-5/8 [T=naFP]\r 55 | ip address 10.240.129.82 255.255.255.252\r 56 | link-aggregate configure timeout short\r 57 | link-aggregate configure key 10001\r 58 | link-aggregate active\r 59 | ! 60 | end\r 61 | \r 62 | %s""" % cli_prompt 63 | 64 | self.show_running_config_result = """Current configuration: 65 | ! 66 | ver 04.2.00d 67 | ! 68 | interface ethernet 17 69 | port-name cs01.cd.xe-5/8 [T=naFP] 70 | ip address 10.240.129.82 255.255.255.252 71 | link-aggregate configure timeout short 72 | link-aggregate configure key 10001 73 | link-aggregate active 74 | ! 75 | end 76 | 77 | """ 78 | # Commands and responses from the perspective of the device. 79 | command_response_dict = { 80 | '__logged_in__': cli_prompt, 81 | 'configure terminal': configure_prompt, 82 | 'hostname xxx': configure_prompt, 83 | 'end': cli_prompt, 84 | 'wr mem': cli_prompt, 85 | 'exit': cli_prompt, 86 | 'skip-page-display\r': 'Disable page display mode\r\n%s' % cli_prompt, 87 | 'show running-config\r': config_snippet} 88 | ssh_client = fake_ssh_connection.FakeSshClient(command_response_dict) 89 | self.device = brocade.BrocadeTiDevice( 90 | host='cdzncsa1switch', ssh_client=ssh_client) 91 | 92 | def testShowRunningConfig(self): 93 | self.device._Connect(username='userX', password='passwordX', 94 | enable_password='enableX') 95 | response = self.device._Cmd('show running-config') 96 | self.assertEqual(self.show_running_config_result, response) 97 | 98 | 99 | if __name__ == '__main__': 100 | unittest.main() 101 | -------------------------------------------------------------------------------- /cisconx.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # 3 | # Copyright 2014 Google Inc. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | """A push implementation for Cisco Nexus(NX-OS) devices. 17 | 18 | This module implements the base device interface of base_device.py for 19 | Cisco NX-OS devices. 20 | """ 21 | 22 | from absl import flags as gflags 23 | 24 | import paramiko_device 25 | import push_exceptions as exceptions 26 | 27 | FLAGS = gflags.FLAGS 28 | 29 | gflags.DEFINE_float('cisconx_timeout_response', None, 30 | 'Cisco nexus device response timeout in seconds.') 31 | gflags.DEFINE_float('cisconx_timeout_connect', None, 32 | 'Cisco nexus device connect timeout in seconds.') 33 | gflags.DEFINE_float('cisconx_timeout_idle', None, 34 | 'Cisco nexus device idle timeout in seconds.') 35 | gflags.DEFINE_float('cisconx_timeout_disconnect', None, 36 | 'Cisco nexus device disconnect timeout in seconds.') 37 | gflags.DEFINE_float('cisconx_timeout_act_user', None, 38 | 'Cisco nexus device user activation timeout in seconds.') 39 | 40 | INVALID_OUT = 'Cmd exec error.' 41 | # eg:. 42 | # [ mijith@pulsar: ~ ]. 43 | # $ ssh gmonitor@us-mtv-43-fabsw1.mtv 'foo'. 44 | # Syntax error while parsing 'foo'. 45 | # 46 | # Cmd exec error. 47 | 48 | 49 | class CiscoNexusDevice(paramiko_device.ParamikoDevice): 50 | """A base device model suitable for Cisco Nexus devices. 51 | 52 | See the base_device.BaseDevice method docstrings. 53 | """ 54 | 55 | def __init__(self, **kwargs): 56 | self.vendor_name = 'cisconx' 57 | super(CiscoNexusDevice, self).__init__(**kwargs) 58 | 59 | def _Cmd(self, command, mode=None): 60 | """Cisco Nexus wrapper for ParamikoDevice._Cmd().""" 61 | 62 | result = super(CiscoNexusDevice, self)._Cmd(command, mode) 63 | # On Successful execution of a command. 64 | # ssh gmonitor@us-mtv-43-fabsw1.mtv 'show version'. 65 | # Password:. 66 | # Cisco Nexus Operating System (NX-OS) Software 67 | # TAC support: http://www.cisco.com/tac. 68 | # [output truncated]. 69 | 70 | # Incomplete Command Example. 71 | # [ mijith@pulsar: ~ ]. 72 | # $ ssh gmonitor@us-mtv-43-fabsw1.mtv 'show' 73 | # Syntax error while parsing 'show'. 74 | # Cmd exec error. 75 | 76 | # Invalid Command Example. 77 | # [ mijith@pulsar: ~ ]. 78 | # $ ssh gmonitor@us-mtv-43-fabsw1.mtv 'foo'. 79 | # Syntax error while parsing 'foo'. 80 | # Cmd exec error. 81 | 82 | if result.endswith(INVALID_OUT): 83 | raise exceptions.CmdError('INVALID COMMAND: %s' % command) 84 | 85 | return result 86 | -------------------------------------------------------------------------------- /ciscoxr.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # 3 | # Copyright 2014 Google Inc. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | """A Cisco XR device . 17 | 18 | This module implements the base device interface of base_device.py for 19 | CiscoXR devices. 20 | """ 21 | 22 | 23 | from absl import flags as gflags 24 | 25 | import paramiko_device 26 | import push_exceptions as exceptions 27 | 28 | FLAGS = gflags.FLAGS 29 | 30 | gflags.DEFINE_float('ciscoxr_timeout_response', None, 31 | 'CiscoXR device response timeout in seconds.') 32 | gflags.DEFINE_float('ciscoxr_timeout_connect', None, 33 | 'CiscoXR device connect timeout in seconds.') 34 | gflags.DEFINE_float('ciscoxr_timeout_idle', None, 35 | 'CiscoXR device idle timeout in seconds.') 36 | gflags.DEFINE_float('ciscoxr_timeout_disconnect', None, 37 | 'CiscoXR device disconnect timeout in seconds.') 38 | gflags.DEFINE_float('ciscoxr_timeout_act_user', None, 39 | 'CiscoXR device user activation timeout in seconds.') 40 | 41 | # pylint: disable=arguments-differ 42 | # 38:CiscoxrDevice._Cmd: Arguments number differs from overridden method. 43 | 44 | 45 | class CiscoxrDevice(paramiko_device.ParamikoDevice): 46 | """A base device model suitable for CiscoXR devices. 47 | 48 | See the base_device.BaseDevice method docstrings. 49 | """ 50 | 51 | def __init__(self, **kwargs): 52 | self.vendor_name = 'ciscoxr' 53 | super(CiscoxrDevice, self).__init__(**kwargs) 54 | 55 | def _Cmd(self, command, mode=None): 56 | """CiscoXR wrapper for ParamikoDevice._Cmd().""" 57 | 58 | result = super(CiscoxrDevice, self)._Cmd(command, mode) 59 | if result.endswith("% Invalid input detected at '^' marker.\r\n"): 60 | raise exceptions.CmdError('Invalid input: %s' % command) 61 | if result.endswith('% Bad hostname or protocol not running\r\n'): 62 | raise exceptions.CmdError( 63 | 'Bad hostname or protocol not running: %s' % command) 64 | if result.endswith('% Incomplete command.\r\n'): 65 | raise exceptions.CmdError('Incomplete command: %s' % command) 66 | return result 67 | -------------------------------------------------------------------------------- /fake_ssh_connection.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # 3 | # Copyright 2014 Google Inc. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | """Fake classes for unit tests. 17 | 18 | The class FakeSshClient is a fake for paramiko.SSHClient, it implements a very 19 | minimal set of methods just enough too stub out paramiko.SSHClient when used in 20 | unit test for clients based on pexpect_client.ParamikoSshConnection. 21 | The classes FakeChannel and FakeTransport are substitutes for their paramiko 22 | counterparts Channel and Transport. 23 | """ 24 | import re 25 | import socket 26 | import time 27 | 28 | 29 | # pylint: disable=g-bad-name 30 | class Error(Exception): 31 | pass 32 | 33 | 34 | class FakeChannelError(Error): 35 | """An error occured in the fake Channel class.""" 36 | 37 | 38 | class FakeTransport(object): 39 | """A fake transport class for unit test purposes.""" 40 | 41 | def __init__(self): 42 | self.active = True 43 | 44 | def is_active(self): 45 | return self.active 46 | 47 | 48 | class FakeChannel(object): 49 | """A fake channel class for unit test purposes.""" 50 | 51 | def __init__(self, command_response_dict, exact=True): 52 | """Initialize FakeChannel. 53 | 54 | Args: 55 | command_response_dict: A dict, where if d[sent] defines how to respond 56 | to sent. d[sent] can be either a str, a list of str or a callable 57 | which will be called to create the response. If the response 58 | is a list, each entry is returned on this and subsequent calls of recv. 59 | exact: a bool, If True, treat sent as a string rather than a regexp. 60 | """ 61 | self.command_responses = [] 62 | for receive_re, send_gen in command_response_dict.iteritems(): 63 | if exact: 64 | receive_re = re.escape(receive_re) 65 | if not callable(send_gen): 66 | # send_gen() = send_gen 67 | send_gen = (lambda(s): lambda: s)(send_gen) 68 | self.command_responses.append((re.compile(receive_re), send_gen)) 69 | self.transport = FakeTransport() 70 | self.timeout = None 71 | self.last_sent = '__logged_in__' 72 | self.sent = [] 73 | self.extras = [] 74 | 75 | def set_combine_stderr(self, unused_arg): 76 | pass 77 | 78 | def get_id(self): 79 | return 1 80 | 81 | def get_transport(self): 82 | return self.transport 83 | 84 | def settimeout(self, timeout): 85 | self.timeout = timeout 86 | 87 | def recv(self, unused_size): 88 | """Respond to what was last sent.""" 89 | if self.extras: 90 | return self.extras.pop(0) 91 | if self.last_sent is not None: 92 | last_sent = self.last_sent 93 | self.last_sent = None 94 | for pattern, response in self.command_responses: 95 | if pattern.match(last_sent): 96 | responses = response() 97 | if isinstance(responses, list): 98 | self.extras = responses[1:] 99 | return responses[0] 100 | return responses 101 | raise FakeChannelError('unknown input %r' % last_sent) 102 | time.sleep(self.timeout) 103 | raise socket.timeout('fake timeout') 104 | 105 | def send(self, command): 106 | self.last_sent = command 107 | self.sent.append(command) 108 | 109 | 110 | class FakeSshClient(object): 111 | """A fake SSH client class for unit test purposes.""" 112 | 113 | def __init__(self, command_response_dict, exact=True): 114 | """Initialises a FakeSshClient. 115 | 116 | Args: 117 | command_response_dict: A dict, where the values are either strings 118 | or parameter-free callables returning a string and the keys 119 | are regular expressions. 120 | exact: A boolean, If True, the keys above are plain strings. 121 | 122 | A fake ssh that matches sent data defined by the keys 123 | in command_response_dict and responds with the corresponding string value. 124 | """ 125 | 126 | self.channel = FakeChannel(command_response_dict, exact) 127 | 128 | def Connect(self, **unused_kwargs): 129 | return self 130 | 131 | def invoke_shell(self): 132 | return self.channel 133 | -------------------------------------------------------------------------------- /hp.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # 3 | # Copyright 2014 Google Inc. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | """An HP ProCurve switch device . 17 | 18 | This module implements the base device interface of base_device.py for 19 | Hewlett-Packard ProCurve Ethernet switches. 20 | """ 21 | 22 | import os 23 | import re 24 | 25 | import pexpect 26 | 27 | from absl import flags as gflags 28 | import logging 29 | 30 | import base_device 31 | import pexpect_connection 32 | import push_exceptions as exceptions 33 | 34 | 35 | FLAGS = gflags.FLAGS 36 | 37 | 38 | gflags.DEFINE_float('hp_timeout_response', None, 39 | 'HP device response timeout in seconds.') 40 | gflags.DEFINE_float('hp_timeout_connect', 22.0, 41 | 'HP device connect timeout in seconds.') 42 | gflags.DEFINE_float('hp_timeout_idle', None, 43 | 'HP device idle timeout in seconds.') 44 | gflags.DEFINE_float('hp_timeout_disconnect', None, 45 | 'HP device disconnect timeout in seconds.') 46 | gflags.DEFINE_float('hp_timeout_act_user', None, 47 | 'HP device user activation timeout in seconds.') 48 | 49 | 50 | class HpDevice(base_device.BaseDevice): 51 | """A base device model for Hewlett-Packard ProCurve switches.""" 52 | 53 | RE_INVALID = re.compile(r'^(Invalid|Ambiguous) input:', re.I | re.M) 54 | RE_PAGER = re.compile(r'-- MORE --, next page: Space, next line: Enter, ' 55 | 'quit: Control-C') 56 | 57 | def __init__(self, **kwargs): 58 | self.vendor_name = 'hp' 59 | super(HpDevice, self).__init__(**kwargs) 60 | 61 | # The response regexp indicating connection success. 62 | self._success = r'ProCurve .*[Ss]witch' 63 | 64 | def _Connect(self, username, password=None, ssh_keys=None, 65 | enable_password=None, ssl_cert_set=None): 66 | # Quieten pylint. 67 | _ = ssl_cert_set 68 | self._connection = pexpect_connection.HpSshFilterConnection( 69 | self.loopback_ipv4, username, password, success=self._success, 70 | timeout=self.timeout_connect, find_prompt=True, ssh_keys=ssh_keys, 71 | enable_password=enable_password) 72 | try: 73 | self._connection.Connect() 74 | self._DisablePager() 75 | except pexpect_connection.ConnectionError, e: 76 | self.connected = False 77 | raise exceptions.ConnectError(e) 78 | except pexpect_connection.TimeoutError, e: 79 | self.connected = False 80 | raise exceptions.ConnectError('Timed out connecting to %s(%s) after ' 81 | '%s seconds.' % 82 | (self.host, self.loopback_ipv4, str(e))) 83 | 84 | def _Cmd(self, command, mode=None, called_already=False): 85 | _ = mode 86 | # Strip question marks and short-circuit if we have nothing more. 87 | command = command.replace('?', '') 88 | if not command: 89 | return '' 90 | 91 | try: 92 | self._connection.child.send(command+'\r') 93 | self._connection.child.expect(command+'\n') 94 | result = '' 95 | while True: 96 | i = self._connection.child.expect([self._connection.re_prompt, 97 | self.RE_PAGER], 98 | timeout=self.timeout_response, 99 | searchwindowsize=128) 100 | # HP prefers \n\r to \r\n. 101 | result += self._connection.child.before.replace('\n\r', os.linesep) 102 | if i == 1: 103 | self._connection.child.send(' ') 104 | else: 105 | break 106 | # Check if the device told us our command was not recognized. 107 | if self.RE_INVALID.search(result) is not None: 108 | raise exceptions.CmdError('Command %r invalid on %s(%s)' % 109 | (command, self.host, self.loopback_ipv4)) 110 | return result 111 | except pexpect.TIMEOUT, e: 112 | self.connected = False 113 | raise exceptions.CmdError('%s: %s' % (e.__class__, str(e))) 114 | except pexpect.EOF, e: 115 | if not called_already: 116 | return self._Cmd(command, mode=mode, called_already=True) 117 | else: 118 | self.connected = False 119 | raise exceptions.CmdError('%s: %s' % (e.__class__, str(e))) 120 | 121 | def _DisablePager(self): 122 | """Enables and logs in so the pager can be disabled.""" 123 | # Maximum terminal size on sw version M.08.74 (8095) is 1920x1000. 124 | try: 125 | self._connection.child.send('terminal length 1000\r') 126 | self._connection.child.expect(self._connection.re_prompt, 127 | timeout=self.timeout_act_user, 128 | searchwindowsize=128) 129 | self._connection.child.send('terminal width 1920\r') 130 | self._connection.child.expect(self._connection.re_prompt, 131 | timeout=self.timeout_act_user, 132 | searchwindowsize=128) 133 | except (pexpect.EOF, pexpect.TIMEOUT), e: 134 | self.connected = False 135 | raise exceptions.CmdError('%s: %s' % (e.__class__, str(e))) 136 | 137 | def _Disconnect(self): 138 | """Disconnects from the device.""" 139 | if hasattr(self, '_connection'): 140 | try: 141 | try: 142 | self._connection.child.send('exit\r') 143 | while True: 144 | i = self._connection.child.expect([r'\S(?:#|>) ', 145 | r'Do you want to log out', 146 | r'Do you want to save'], 147 | timeout=self.timeout_act_user) 148 | if i == 0: 149 | self._connection.child.send('exit\r') 150 | continue 151 | elif i == 1: 152 | self._connection.child.send('y') 153 | return 154 | elif i == 2: 155 | self._connection.child.send('n') 156 | logging.warn('Uncomitted config on %s(%s). Not saving.', 157 | self.host, self.loopback_ipv4) 158 | return 159 | except pexpect.TIMEOUT, e: 160 | raise exceptions.DisconnectError('%s: %s' % (e.__class__, str(e))) 161 | except pexpect.EOF, e: 162 | # An EOF now means nothing more than a disconnect. 163 | pass 164 | finally: 165 | self.connected = False 166 | -------------------------------------------------------------------------------- /ios.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # 3 | # Copyright 2014 Google Inc. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | """A Cisco IOS devicemodel. 17 | 18 | This module implements a device interface of base_device.py for 19 | most of the herd of variants of Cisco IOS devices. 20 | """ 21 | 22 | import hashlib 23 | import os 24 | import re 25 | import time 26 | 27 | import pexpect 28 | 29 | from absl import flags as gflags 30 | import logging 31 | 32 | import base_device 33 | import pexpect_connection 34 | import push_exceptions as exceptions 35 | 36 | FLAGS = gflags.FLAGS 37 | 38 | gflags.DEFINE_float('ios_timeout_response', None, 39 | 'IOS device response timeout in seconds.') 40 | gflags.DEFINE_float('ios_timeout_connect', None, 41 | 'IOS device connect timeout in seconds.') 42 | gflags.DEFINE_float('ios_timeout_idle', None, 43 | 'IOS device idle timeout in seconds.') 44 | gflags.DEFINE_float('ios_timeout_disconnect', None, 45 | 'IOS device disconnect timeout in seconds.') 46 | gflags.DEFINE_float('ios_timeout_act_user', None, 47 | 'IOS device user activation timeout in seconds.') 48 | 49 | MD5_RE = re.compile(r'verify /md5 \(\S+\)\s+=\s+([A-Fa-f0-9]+)') 50 | # Used in sleep statements for a minor pause. 51 | MINOR_PAUSE = 0.05 52 | 53 | # Some Cisco ways of saying 'access denied' and/or 'invalid command'. 54 | # Due to the way Cisco privilege levels work and since unknown commands 55 | # may be looked up in DNS, any of these could be a response which really 56 | # means 'access denied', or they could mean what they say. 57 | INVALID_1 = "% Invalid input detected at '^' marker.\n\n" 58 | INVALID_2 = ('% Unknown command or computer name, or unable to find computer ' 59 | 'address\n') 60 | INVALID_3 = 'Command authorization failed.\n\n' 61 | INVALID_4 = '% Authorization failed.\n\n' 62 | INVALID_5 = '% Incomplete command.\n\n' 63 | INVALID_6_PREFIX = '% Ambiguous command:' 64 | 65 | 66 | class DeleteFileError(Exception): 67 | """A file was not successfully deleted.""" 68 | 69 | 70 | class IosDevice(base_device.BaseDevice): 71 | """A device model for devices with IOS-like interfaces.""" 72 | 73 | def __init__(self, **kwargs): 74 | self.vendor_name = 'ios' 75 | super(IosDevice, self).__init__(**kwargs) 76 | 77 | # The response regexp indicating connection success. 78 | self._success = r'(?:^|\n)([]A-Za-z0-9\.\-[]+[>#])' 79 | 80 | def _Connect(self, username, password=None, ssh_keys=None, 81 | enable_password=None, ssl_cert_set=None): 82 | _ = ssl_cert_set 83 | self._connection = pexpect_connection.ParamikoSshConnection( 84 | self.loopback_ipv4, username, password, self._success, 85 | timeout=self.timeout_connect, find_prompt=True, ssh_keys=ssh_keys, 86 | enable_password=enable_password) 87 | try: 88 | self._connection.Connect() 89 | self._DisablePager() 90 | self.connected = True 91 | except pexpect_connection.ConnectionError as e: 92 | self.connected = False 93 | raise exceptions.ConnectError(e) 94 | except pexpect_connection.TimeoutError as e: 95 | self.connected = False 96 | raise exceptions.ConnectError('Timed out connecting to %s(%s) after ' 97 | '%s seconds.' % 98 | (self.host, self.loopback_ipv4, str(e))) 99 | 100 | def _Cmd(self, command, mode=None): 101 | 102 | def SendAndWait(command): 103 | """Sends a command and waits for a response.""" 104 | self._connection.child.send(command+'\r') 105 | self._connection.child.expect('\r\n', timeout=self.timeout_response) 106 | self._connection.child.expect(self._connection.re_prompt, 107 | timeout=self.timeout_response, 108 | searchwindowsize=128) 109 | return self._connection.child.before.replace('\r\n', os.linesep) 110 | 111 | # Quieten pylint. 112 | _ = mode 113 | # We strip question-marks ('?') from the input as they upset the 114 | # buffering for minimal gain (they work only on IOS and not on FTOS). 115 | command = command.replace('?', '') 116 | result = '' 117 | try: 118 | result = SendAndWait(command) 119 | except pexpect.TIMEOUT as e: 120 | self.connected = False 121 | raise exceptions.CmdError('%s: %s' % (e.__class__, str(e))) 122 | except pexpect.EOF: 123 | # Retry once on EOF error, in case we have been idle disconnected. 124 | try: 125 | self.connected = False 126 | self._connection.Connect() 127 | self._DisablePager() 128 | self.connected = True 129 | result = SendAndWait(command) 130 | except pexpect.EOF: 131 | raise exceptions.CmdError('Failed with EOF error twice.') 132 | except pexpect_connection.ConnectionError as e: 133 | raise exceptions.CmdError('Auto-reconnect failed: %s' % e) 134 | except pexpect_connection.TimeoutError as e: 135 | raise exceptions.CmdError('Auto-reconnect timed out: %s' % e) 136 | 137 | # Fix trailing \r to \n (if \n of last \r\n is captured by prompt). 138 | if result and result[-1] == '\r': 139 | result = result[:-1] + '\n' 140 | 141 | if (result.endswith(INVALID_1) or result.endswith(INVALID_2) or 142 | result.endswith(INVALID_3) or result.endswith(INVALID_4) or 143 | result.endswith(INVALID_5) or ( 144 | result.endswith('\n') and 145 | result[result[:-1].rfind('\n') + 1:].startswith( 146 | INVALID_6_PREFIX))): 147 | raise exceptions.CmdError('Command failed: %s' % result) 148 | 149 | return result 150 | 151 | def _SetConfig(self, destination_file, data, canary): 152 | # Canarying is not supported on IOS. 153 | if canary: 154 | raise exceptions.SetConfigCanaryingError('%s devices do not support ' 155 | 'configuration canarying.' % 156 | self.vendor_name) 157 | # We only support copying to 'running-config' or 'startup-config' on IOS. 158 | if destination_file not in ('running-config', 'startup-config'): 159 | raise exceptions.SetConfigError('destination_file argument must be ' 160 | '"running-config" or "startup-config" ' 161 | 'for %s devices.' % self.vendor_name) 162 | # Result object. 163 | result = base_device.SetConfigResult() 164 | 165 | # Get the MD5 sum of the file. 166 | local_digest = hashlib.md5(data).hexdigest() 167 | 168 | try: 169 | # Get the working path from the remote device 170 | # remote_path = self._Cmd('pwd') 171 | remote_path = 'nvram:/' 172 | except exceptions.CmdError as e: 173 | msg = 'Error obtaining working directory: %s' % e 174 | logging.error(msg) 175 | raise exceptions.SetConfigError(msg) 176 | 177 | # Use a random remote file name 178 | remote_tmpfile = '%s/push.%s' % ( 179 | remote_path.rstrip(), os.urandom(8).encode('hex')) 180 | 181 | # Upload the file to the device. 182 | scp = pexpect_connection.ScpPutConnection( 183 | self.loopback_ipv4, 184 | username=self._username, 185 | password=self._password) 186 | try: 187 | scp.Copy(data, remote_tmpfile) 188 | except pexpect_connection.Error as e: 189 | raise exceptions.SetConfigError( 190 | 'Failed to copy configuration to remote device. %s' % str(e)) 191 | 192 | # Get the file size on the router. 193 | try: 194 | # Get the MD5 hexdigest of the file on the remote device. 195 | try: 196 | verify_output = self._Cmd('verify /md5 %s' % remote_tmpfile) 197 | match = MD5_RE.search(verify_output) 198 | if match is not None: 199 | remote_digest = match.group(1) 200 | else: 201 | raise exceptions.SetConfigError( 202 | 'The "verify /md5 " command did not produce ' 203 | 'expected results. It returned: %r' % verify_output) 204 | except exceptions.CmdError as e: 205 | raise exceptions.SetConfigError( 206 | 'The MD5 hash command on the router did not succed. ' 207 | 'The device may not support: "verify /md5 "') 208 | # Verify the local_digest and remote_digest are the same. 209 | if local_digest != remote_digest: 210 | raise exceptions.SetConfigError( 211 | 'File transfer to remote host corrupted. Local digest: %r, ' 212 | 'Remote digest: %r' % (local_digest, remote_digest)) 213 | 214 | # Copy the file from flash to the 215 | # destination(running-config, startup-config). 216 | # Catch errors that may occur during application, and report 217 | # these to the user. 218 | try: 219 | self._connection.child.send( 220 | 'copy %s %s\r' % (remote_tmpfile, destination_file)) 221 | pindex = self._connection.child.expect( 222 | [r'Destination filename \[%s\]\?' % destination_file, 223 | r'%\s*\S*.*', 224 | r'%Error.*', 225 | self._connection.re_prompt], 226 | timeout=self.timeout_act_user) 227 | if pindex == 0: 228 | self._connection.child.send('\r') 229 | try: 230 | pindex = self._connection.child.expect( 231 | [r'Invalid input detected', 232 | self._connection.re_prompt, 233 | r'%Warning:There is a file already existing.*' 234 | 'Do you want to over write\? \[confirm\]'], 235 | timeout=self.timeout_act_user) 236 | if pindex == 0: 237 | # Search again using findall to get all bad lines. 238 | bad_lines = re.findall( 239 | r'^(.*)$[\s\^]+% Invalid input', 240 | self._connection.child.match.string, 241 | re.MULTILINE) 242 | raise exceptions.SetConfigSyntaxError( 243 | 'Configuration loaded, but with bad lines:\n%s' % 244 | '\n'.join(bad_lines)) 245 | if pindex == 2: 246 | # Don't over-write. 247 | self._connection.child.send('n') 248 | raise exceptions.SetConfigError( 249 | 'Destination file %r already exists, cannot overwrite.' 250 | % destination_file) 251 | except (pexpect.EOF, pexpect.TIMEOUT) as e: 252 | raise exceptions.SetConfigError( 253 | 'Copied file to device, but did not ' 254 | 'receive prompt afterwards. %s %s' % 255 | (self._connection.child.before, self._connection.child.after)) 256 | 257 | elif pindex == 2: 258 | print "MATCHED 2" 259 | # The expect does a re.search, search again using findall to get all 260 | raise exceptions.SetConfigError('Could not copy temporary ' 261 | 'file to %s.' % destination_file) 262 | except (pexpect.EOF, pexpect.TIMEOUT) as e: 263 | raise exceptions.SetConfigError( 264 | 'Attempted to copy to bootflash, but a timeout occurred.') 265 | 266 | # We need to 'write memory' if we are doing running-config. 267 | if destination_file == 'running-config': 268 | logging.debug('Attempting to copy running-config to startup-config ' 269 | 'on %s(%s)', self.host, self.loopback_ipv4) 270 | try: 271 | self._Cmd('wr mem') 272 | except exceptions.CmdError as e: 273 | raise exceptions.SetConfigError('Failed to write startup-config ' 274 | 'for %s(%s). Changes applied. ' 275 | 'Error was: %s' % 276 | (self.host, self.loopback_ipv4, 277 | str(e))) 278 | finally: 279 | try: 280 | self._DeleteFile(remote_tmpfile) 281 | except DeleteFileError as e: 282 | result.transcript = 'SetConfig warning: %s' % str(e) 283 | logging.warn(result.transcript) 284 | 285 | # And finally, return the result text. 286 | return result 287 | 288 | def _DeleteFile(self, file_name): 289 | """Delete a file. 290 | 291 | Args: 292 | file_name: A string, the file name. 293 | 294 | Raises: 295 | DeleteFileError, if the deletion failed. 296 | """ 297 | try: 298 | self._connection.child.send('\r') 299 | self._connection.child.expect('\r\n', timeout=self.timeout_act_user) 300 | self._connection.child.expect(self._connection.re_prompt, 301 | timeout=self.timeout_act_user, 302 | searchwindowsize=128) 303 | self._connection.child.send('delete %s\r' % file_name) 304 | except pexpect.ExceptionPexpect: 305 | raise DeleteFileError('DeleteFile operation failed. %s' % 306 | self._connection.child) 307 | 308 | try: 309 | pindex = self._connection.child.expect( 310 | [r'Delete filename \[.*\]\?', 311 | r'%.*Error.*'], 312 | timeout=self.timeout_act_user) 313 | if pindex == 0: 314 | self._connection.child.send('\r') 315 | logging.debug('DeleteFile: answering first confirmation.') 316 | self._connection.child.expect([r'Delete .*\[confirm\]'], 317 | timeout=self.timeout_act_user) 318 | logging.debug('DeleteFile: answering second confirmation.') 319 | self._connection.child.send('\r') 320 | elif pindex == 1: 321 | raise DeleteFileError('DeleteFile operation failed. %s' % 322 | self._connection.child.match) 323 | 324 | pindex = self._connection.child.expect([self._connection.re_prompt, 325 | r'%.*Error.*'], 326 | timeout=self.timeout_act_user) 327 | if pindex == 1: 328 | raise DeleteFileError('DeleteFile operation failed. %s' % 329 | self._connection.child.match) 330 | logging.debug('DeleteFile: success.') 331 | except pexpect.ExceptionPexpect: 332 | raise DeleteFileError('DeleteFile operation failed. %s' % 333 | self._connection.child) 334 | 335 | def _GetConfig(self, source): 336 | try: 337 | if source in ('running-config', 'startup-config'): 338 | result = self._Cmd('show %s' % source) 339 | else: 340 | raise exceptions.GetConfigError('source argument must be ' 341 | '"running-config" or ' 342 | '"startup-config".') 343 | if not result: 344 | return exceptions.EmptyConfigError('%s has an empty configuration.' % 345 | self.host) 346 | else: 347 | return result 348 | except exceptions.Error as e: 349 | raise exceptions.GetConfigError('Could not fetch config from %s. %s.' % 350 | (self.host, str(e))) 351 | 352 | def _Disconnect(self): 353 | if hasattr(self, '_connection'): 354 | try: 355 | self._connection.child.send('exit\r') 356 | self._connection.child.expect(self._connection.exit_list, 357 | timeout=self.timeout_act_user) 358 | self.connected = False 359 | except (pexpect.EOF, pexpect.TIMEOUT) as e: 360 | self.connected = False 361 | raise exceptions.DisconnectError('%s: %s' % (e.__class__, str(e))) 362 | 363 | def _DisablePager(self): 364 | """Disables the pager.""" 365 | try: 366 | self._connection.child.send('\r') 367 | self._connection.child.expect(r'\r\n', 368 | timeout=self.timeout_connect) 369 | self._connection.child.expect(self._connection.re_prompt, 370 | timeout=self.timeout_connect, 371 | searchwindowsize=128) 372 | self._connection.child.send('terminal length 0\r') 373 | pindex = self._connection.child.expect( 374 | [self._connection.re_prompt, r'Command authorization failed\.'], 375 | timeout=self.timeout_connect) 376 | if pindex == 1: 377 | self.connected = False 378 | raise exceptions.ConnectError('terminal length 0 command denied.') 379 | # Pause momentarily to avoid a TAC+ packet drop. 380 | time.sleep(0.5) 381 | except (pexpect.EOF, pexpect.TIMEOUT) as e: 382 | self.connected = False 383 | raise exceptions.ConnectError('%s: %s' % (e.__class__, str(e))) 384 | logging.debug('terminal length set to 0') 385 | -------------------------------------------------------------------------------- /junos.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # 3 | # Copyright 2014 Google Inc. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | """A JunOS device. 17 | 18 | This module implements the device interface of base_device.py for 19 | Juniper Networks' devices running the JunOS operating system. 20 | These devices are typically routers, such as the T640 and MX960. 21 | """ 22 | 23 | import hashlib 24 | import os 25 | import re 26 | import tempfile 27 | import threading 28 | 29 | import paramiko 30 | 31 | from absl import flags as gflags 32 | import logging 33 | 34 | import base_device 35 | import paramiko_device 36 | import push_exceptions as exceptions 37 | 38 | 39 | FLAGS = gflags.FLAGS 40 | 41 | gflags.DEFINE_float('junos_timeout_response', None, 42 | 'JunOS device response timeout in seconds.') 43 | gflags.DEFINE_float('junos_timeout_connect', None, 44 | 'JunOS device connect timeout in seconds.') 45 | gflags.DEFINE_float('junos_timeout_idle', None, 46 | 'JunOS device idle timeout in seconds.') 47 | gflags.DEFINE_float('junos_timeout_disconnect', None, 48 | 'JunOS device disconnect timeout in seconds.') 49 | gflags.DEFINE_float('junos_timeout_act_user', None, 50 | 'JunOS device user activation timeout in seconds.') 51 | gflags.DEFINE_boolean('paramiko_logging', 52 | False, 53 | 'Log Paramiko output to STDERR found.') 54 | 55 | 56 | class JunosDevice(paramiko_device.ParamikoDevice): 57 | """A device model suitable for Juniper JunOS devices. 58 | 59 | See the base_device.BaseDevice method docstrings. 60 | """ 61 | # Used to protect the SetupParamikoLogging method, and state. 62 | _paramiko_logging_lock = threading.Lock() 63 | _paramiko_logging_initialized = False 64 | 65 | # Response strings that indicate an error during SetConfig(). 66 | JUNOS_LOAD_ERRORS = ('error:', 67 | ' errors', 68 | 'error recovery ignores input until this point:') 69 | 70 | # Response strings that do *not* indicate an error, and should be ignored 71 | # This currently matches that start with a "diff" character, or indicate a 72 | # failure to communicate with an RE. 73 | IGNORED_JUNOS_LINES = re.compile( 74 | r'^[+!-]|' # Match diff characters at start of line. 75 | r'(? rapid'). 191 | command += ' count 5' 192 | # Enforce that traceroute and monitor have the stderr (header) and stdout 193 | # merged, in that order. 194 | if command.startswith('tr') or command.startswith('mo'): 195 | merge_stderr_first = True 196 | else: 197 | merge_stderr_first = False 198 | # Run the modified command. 199 | result = super(JunosDevice, self)._Cmd( 200 | command, mode=mode, 201 | merge_stderr_first=merge_stderr_first, 202 | require_low_chanid=True) 203 | # Report JunOS errors on stdout as CmdError. 204 | if result.startswith('\nerror: '): 205 | raise exceptions.CmdError(result[1:]) # Drop the leading \n. 206 | else: 207 | return result 208 | 209 | def _ChecksumsMatch(self, local_file_name, remote_file_name): 210 | """Compares the local and remote checksums for the named file. 211 | 212 | Args: 213 | local_file_name: A string, the filename on the local host. 214 | remote_file_name: A string, the file path and name of the remote file. 215 | 216 | Returns: 217 | A boolean. True iff the checksums match, else False. 218 | """ 219 | remote_md5 = self._Cmd('file checksum md5 ' + remote_file_name) 220 | logging.debug('Remote checksum output: %s', remote_md5) 221 | local_md5 = hashlib.md5(open(local_file_name).read()).hexdigest() 222 | logging.debug('Local checksum: %s', local_md5) 223 | try: 224 | if local_md5 == remote_md5.split()[3]: 225 | logging.debug('PASS MD5 checksums match.') 226 | return True 227 | else: 228 | logging.error('FAIL MD5 checksums do not match.') 229 | return False 230 | except IndexError: 231 | logging.error('ERROR MD5 checksum parse error.') 232 | logging.error('ERROR local checksum: %r', local_md5) 233 | logging.error('ERROR remote checksum: %r', remote_md5) 234 | return False 235 | 236 | def _GetConfig(self, source_file): 237 | """Gets file or running configuration from the remote device. 238 | 239 | Args: 240 | source_file: A string, containing path to the file that should be 241 | retrieved from the remote device. It can also contain the defined 242 | reserved word self.CONFIG_RUNNING, in which case this method 243 | retrieves the running configuration from the remote device. 244 | 245 | Returns: 246 | response: A string, content of the retrieved file or running 247 | configuration. 248 | 249 | Raises: 250 | exceptions.GetConfigError: An error occured during the retrieval. 251 | exceptions.EmptyConfigError: Running configuration is empty. 252 | """ 253 | response = '' 254 | 255 | if source_file == self.CONFIG_RUNNING: 256 | try: 257 | response = self._Cmd('show configuration') 258 | except exceptions.CmdError: 259 | msg = ('Could not retrieve system configuration from %s' % 260 | repr(self.host)) 261 | logging.error(msg) 262 | raise exceptions.GetConfigError(msg) 263 | if not response: 264 | raise exceptions.EmptyConfigError( 265 | 'Configuration of %s is empty' % repr(self.host)) 266 | 267 | else: 268 | tempfile_ptr = tempfile.NamedTemporaryFile() 269 | try: 270 | self._GetFileViaSftp(local_filename=tempfile_ptr.name, 271 | remote_filename=source_file) 272 | except (paramiko.SFTPError, IOError) as e: 273 | msg = ('Could not retrieve configuration file %r from %s, ' 274 | 'error: %s' % (source_file, self.host, e)) 275 | logging.error(msg) 276 | raise exceptions.GetConfigError(msg) 277 | response = tempfile_ptr.read() 278 | 279 | return response 280 | 281 | def _JunosLoad(self, operation, filename, canary=False, 282 | skip_show_compare=False, skip_commit_check=False, 283 | rollback_patch=None): 284 | """Loads the configuration to the remote device using a given operation. 285 | 286 | Args: 287 | operation: A string, the load operation (e.g., 'replace', 'override'). 288 | filename: A string, the remote temporary filename to stage configuration. 289 | canary: A boolean, if True, only canary check the configuration, don't 290 | apply it. 291 | skip_show_compare: A boolean, if True, "show | compare" will be skipped. 292 | This is a temporary flag due to a JunOS bug and may be removed in the 293 | future. 294 | skip_commit_check: A boolean, if True, "commit check" (running the commit 295 | scripts) will be skipped in canary mode. 296 | rollback_patch: None or a string, optional filename into which to 297 | record and return a patch to rollback the config change. 298 | 299 | Returns: 300 | A base_device.SetConfigResult, all responses from the router during the 301 | check/load operation, plus any optional extras. 302 | """ 303 | 304 | show_compare = 'show | compare; ' 305 | if skip_show_compare: 306 | show_compare = '' 307 | if canary: 308 | commit_check = 'commit check; ' 309 | if skip_commit_check: 310 | commit_check = '' 311 | cmd = ('edit exclusive; load %s %s; %s%srollback 0; exit' % 312 | (operation, filename, show_compare, commit_check)) 313 | else: 314 | save_rollback_patch = '' 315 | if rollback_patch: 316 | save_rollback_patch = ('rollback 1; show | compare | save %s; rollback;' 317 | % rollback_patch) 318 | cmd = ('edit exclusive; load %s %s; %s' 319 | 'commit comment "push: load %s %s";%s exit' % 320 | (operation, filename, show_compare, operation, filename, 321 | save_rollback_patch)) 322 | result = base_device.SetConfigResult() 323 | result.transcript = self._Cmd(cmd) 324 | self._RaiseExceptionIfLoadError( 325 | result.transcript, 326 | expect_config_check=canary and not skip_commit_check, 327 | expect_commit=not canary) 328 | return result 329 | 330 | def _SetConfig(self, destination_file, data, canary, skip_show_compare=False, 331 | skip_commit_check=False, get_rollback_patch=False): 332 | copied = False 333 | 334 | file_ptr = tempfile.NamedTemporaryFile() 335 | rollback_patch_ptr = tempfile.NamedTemporaryFile() 336 | rollback_patch = None 337 | # Setting the file name based upon if we are trying to copy a file or 338 | # we are trying to copy a config into the control plane. 339 | if destination_file in self.NON_FILE_DESTINATIONS: 340 | file_name = os.path.basename(file_ptr.name) 341 | if get_rollback_patch: 342 | rollback_patch = os.path.basename(rollback_patch_ptr.name) 343 | else: 344 | file_name = destination_file 345 | logging.info('Remote file path: %s', file_name) 346 | 347 | try: 348 | file_ptr.write(data) 349 | file_ptr.flush() 350 | except IOError: 351 | raise exceptions.SetConfigError('Could not open temporary file %r' % 352 | file_ptr.name) 353 | result = base_device.SetConfigResult() 354 | try: 355 | # Copy the file to the remote device. 356 | try: 357 | self._SendFileViaSftp(local_filename=file_ptr.name, 358 | remote_filename=file_name) 359 | copied = True 360 | except (paramiko.SFTPError, IOError) as e: 361 | # _SendFileViaSftp puts the normalized destination path in e.args[1]. 362 | msg = 'SFTP failed (filename %r to device %s(%s):%s): %s: %s' % ( 363 | file_ptr.name, self.host, self.loopback_ipv4, e.args[1], 364 | e.__class__.__name__, e.args[0]) 365 | raise exceptions.SetConfigError(msg) 366 | 367 | if not self._ChecksumsMatch(local_file_name=file_ptr.name, 368 | remote_file_name=file_name): 369 | raise exceptions.SetConfigError( 370 | 'Local and remote file checksum mismatch.') 371 | 372 | if self.CONFIG_RUNNING == destination_file: 373 | operation = 'replace' 374 | elif self.CONFIG_STARTUP == destination_file: 375 | operation = 'override' 376 | elif self.CONFIG_PATCH == destination_file: 377 | operation = 'patch' 378 | else: 379 | result.transcript = 'SetConfig uploaded the file successfully.' 380 | return result 381 | if canary: 382 | logging.debug('Canary syntax checking configuration file %r.', 383 | file_name) 384 | result = self._JunosLoad(operation, file_name, canary=True, 385 | skip_show_compare=skip_show_compare, 386 | skip_commit_check=skip_commit_check) 387 | else: 388 | logging.debug('Setting destination %r with configuration file %r.', 389 | destination_file, file_name) 390 | result = self._JunosLoad(operation, file_name, 391 | skip_show_compare=skip_show_compare, 392 | skip_commit_check=skip_commit_check, 393 | rollback_patch=rollback_patch) 394 | 395 | if rollback_patch: 396 | try: 397 | self._GetFileViaSftp(local_filename=rollback_patch_ptr.name, 398 | remote_filename=rollback_patch) 399 | result.rollback_patch = rollback_patch_ptr.read() 400 | except (paramiko.SFTPError, IOError) as e: 401 | # _GetFileViaSftp puts the normalized source path in e.args[1]. 402 | result.transcript += ( 403 | 'SFTP rollback patch retrieval failed ' 404 | '(filename %r from device %s(%s):%s): %s: %s' % ( 405 | rollback_patch_ptr.name, self.host, self.loopback_ipv4, 406 | e.args[1], e.__class__.__name__, e.args[0])) 407 | 408 | # Return the diagnostic results as the (optional) result. 409 | return result 410 | 411 | finally: 412 | local_delete_exception = None 413 | # Unlink the original temporary file. 414 | try: 415 | logging.info('Deleting the file on the local machine: %s', 416 | file_ptr.name) 417 | file_ptr.close() 418 | except IOError: 419 | local_delete_exception = exceptions.SetConfigError( 420 | 'Could not close temporary file.') 421 | 422 | local_rollback_patch_delete_exception = None 423 | # Unlink the rollback patch temporary file. 424 | try: 425 | logging.info('Deleting the file on the local machine: %s', 426 | rollback_patch_ptr.name) 427 | rollback_patch_ptr.close() 428 | except IOError: 429 | local_rollback_patch_delete_exception = exceptions.SetConfigError( 430 | 'Could not close temporary rollback patch file.') 431 | 432 | # If we copied the file to the router and we were pushing a configuration, 433 | # delete the temporary file off the router. 434 | if copied and destination_file in self.NON_FILE_DESTINATIONS: 435 | logging.info('Deleting file on the router: %s', file_name) 436 | self.Cmd('file delete ' + file_name) 437 | 438 | # Delete any rollback patch file too. 439 | if rollback_patch: 440 | logging.info('Deleting patch on the router: %s', rollback_patch) 441 | self.Cmd('file delete ' + rollback_patch) 442 | 443 | # If we got an exception on the local file delete, but did not get a 444 | # (more important) exception on the remote delete, raise the local delete 445 | # exception. 446 | # 447 | # pylint is confused by the re-raising 448 | # pylint: disable=raising-bad-type 449 | if local_delete_exception is not None: 450 | raise local_delete_exception 451 | if local_rollback_patch_delete_exception is not None: 452 | raise local_rollback_patch_delete_exception 453 | 454 | def _GetFileViaSftp(self, local_filename, remote_filename): 455 | """Gets the file named remote_filename from the remote device via SFTP. 456 | 457 | Args: 458 | local_filename: A string, the filename (must exist). 459 | remote_filename: A string, the path to the remote file location and 460 | filename. 461 | 462 | Raises: 463 | paramiko.SFTPError: An error occurred during the SFTP. 464 | IOError: There was an IOError accessing the named file. 465 | """ 466 | sftp = self._ssh_client.open_sftp() 467 | try: 468 | sftp.get(remote_filename, local_filename) 469 | except (paramiko.SFTPError, IOError) as e: 470 | try: 471 | remote_filename = sftp.normalize(remote_filename) 472 | except (paramiko.SFTPError, IOError): 473 | pass 474 | raise e.__class__(e.args[0], remote_filename) 475 | finally: 476 | sftp.close() # Request close from peer. 477 | 478 | def _SendFileViaSftp(self, local_filename, remote_filename): 479 | """Sends the file named filename to the remote device via SFTP. 480 | 481 | Args: 482 | local_filename: A string, the filename (must exist). 483 | remote_filename: A string, the path to the remote file location and 484 | filename. 485 | 486 | Returns: 487 | A tuple like stat() returns, the remote file's stat result. 488 | 489 | Raises: 490 | paramiko.SFTPError: An error occurred during the SFTP. 491 | IOError: There was an IOError accessing the named file. 492 | """ 493 | sftp = self._ssh_client.open_sftp() 494 | try: 495 | sftp.put(local_filename, remote_filename) 496 | except (paramiko.SFTPError, IOError) as e: 497 | try: 498 | remote_filename = sftp.normalize(remote_filename) 499 | except (paramiko.SFTPError, IOError): 500 | pass 501 | raise e.__class__(e.args[0], remote_filename) 502 | finally: 503 | sftp.close() # Request close from peer. 504 | -------------------------------------------------------------------------------- /junos_test.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | """Tests for JunOS devices.""" 4 | 5 | import tempfile 6 | import textwrap 7 | import mox 8 | 9 | import unittest 10 | import junos 11 | import paramiko_device 12 | import push_exceptions as exceptions 13 | 14 | 15 | class JunosTest(unittest.TestCase): 16 | 17 | def setUp(self): 18 | self._mox = mox.Mox() 19 | self.device = junos.JunosDevice(host='pr01.dub01') 20 | 21 | def tearDown(self): 22 | self._mox.UnsetStubs() 23 | self._mox.ResetAll() 24 | self._mox.UnsetStubs() 25 | 26 | def testGetConfigSuccessfulConfigTransfer(self): 27 | self._mox.StubOutWithMock(paramiko_device.ParamikoDevice, '_Cmd') 28 | paramiko_device.ParamikoDevice._Cmd( 29 | 'show configuration', mode=None, merge_stderr_first=False, 30 | require_low_chanid=True).AndReturn( 31 | 'Some configuration\n response.') 32 | self._mox.ReplayAll() 33 | response = self.device._GetConfig('running-config') 34 | self._mox.VerifyAll() 35 | self.assertEquals('Some configuration\n response.', response) 36 | 37 | def testGetConfigFailedConfigTransfer(self): 38 | self._mox.StubOutWithMock(paramiko_device.ParamikoDevice, '_Cmd') 39 | paramiko_device.ParamikoDevice._Cmd( 40 | 'show configuration', mode=None, merge_stderr_first=False, 41 | require_low_chanid=True).AndRaise(exceptions.CmdError) 42 | self._mox.ReplayAll() 43 | self.assertRaises(exceptions.GetConfigError, self.device._GetConfig, 44 | 'running-config') 45 | self._mox.VerifyAll() 46 | 47 | def testGetConfigEmptyConfigTransfer(self): 48 | self._mox.StubOutWithMock(paramiko_device.ParamikoDevice, '_Cmd') 49 | paramiko_device.ParamikoDevice._Cmd( 50 | 'show configuration', mode=None, merge_stderr_first=False, 51 | require_low_chanid=True).AndReturn('') 52 | self._mox.ReplayAll() 53 | self.assertRaises(exceptions.EmptyConfigError, self.device._GetConfig, 54 | 'running-config') 55 | self._mox.VerifyAll() 56 | 57 | def testGetConfigSuccessfulFileTransfer(self): 58 | tempfile_ptr = tempfile.NamedTemporaryFile() 59 | tempfile_ptr.write('Fake file content.') 60 | tempfile_ptr.seek(0) 61 | self._mox.StubOutWithMock(tempfile, 'NamedTemporaryFile') 62 | self._mox.StubOutWithMock(junos.JunosDevice, '_GetFileViaSftp') 63 | 64 | tempfile.NamedTemporaryFile().AndReturn(tempfile_ptr) 65 | self.device._GetFileViaSftp(local_filename=tempfile_ptr.name, 66 | remote_filename='/var/tmp/testfile') 67 | self._mox.ReplayAll() 68 | response = self.device._GetConfig('/var/tmp/testfile') 69 | self._mox.VerifyAll() 70 | self.assertEquals('Fake file content.', response) 71 | 72 | def testGetConfigFailedFileTransfer(self): 73 | tempfile_ptr = tempfile.NamedTemporaryFile() 74 | self._mox.StubOutWithMock(tempfile, 'NamedTemporaryFile') 75 | self._mox.StubOutWithMock(junos.JunosDevice, '_GetFileViaSftp') 76 | 77 | tempfile.NamedTemporaryFile().AndReturn(tempfile_ptr) 78 | self.device._GetFileViaSftp( 79 | local_filename=tempfile_ptr.name, 80 | remote_filename='/var/tmp/testfile').AndRaise(IOError) 81 | self._mox.ReplayAll() 82 | self.assertRaises(exceptions.GetConfigError, self.device._GetConfig, 83 | '/var/tmp/testfile') 84 | self._mox.VerifyAll() 85 | 86 | def testCleanupErrorLine(self): 87 | self.assertEquals('', self.device._CleanupErrorLine('')) 88 | self.assertEquals('a', self.device._CleanupErrorLine('a')) 89 | self.assertEquals('invalid value ', 90 | self.device._CleanupErrorLine( 91 | 'invalid value \'257\' in ip address: \'257.0.0.0')) 92 | self.assertEquals('description ', 93 | self.device._CleanupErrorLine('description "foo";')) 94 | self.assertEquals( 95 | '', self.device._CleanupErrorLine('+ description "error: foo";')) 96 | self.assertEquals( 97 | '', self.device._CleanupErrorLine('- description "1 errors";')) 98 | self.assertEquals( 99 | '', self.device._CleanupErrorLine('! description "error foo";')) 100 | self.assertEquals('foo -1', self.device._CleanupErrorLine('foo -1')) 101 | 102 | def testLoadErrors(self): 103 | # Make an alias for the function under test, _RaiseExceptionIfLoadError, 104 | # because writing "self.device._RaiseExceptionIfLoadError" is verbose. 105 | test_function = self.device._RaiseExceptionIfLoadError 106 | 107 | # Check some non-throwing cases. 108 | self.assertTrue(test_function('') is None) 109 | self.assertTrue(test_function('', expect_config_check=True) is None) 110 | self.assertTrue( 111 | test_function('+ description "error: syntax error";', 112 | expect_config_check=True) 113 | is None) 114 | self.assertTrue( 115 | test_function('! description "error: syntax error";', 116 | expect_config_check=True) 117 | is None) 118 | self.assertTrue(test_function('[edit ... ]') is None) 119 | self.assertTrue(test_function('[edit ... ]\n error: foo') is None) 120 | self.assertTrue(test_function('[edit ... ]\n+ error: foo') is None) 121 | missing_re_output = textwrap.dedent("""\ 122 | Entering configuration mode 123 | load complete 124 | 125 | error: Could not connect to re1 : No route to host 126 | warning: Cannot connect to other RE, ignoring it 127 | commit complete 128 | Exiting configuration mode 129 | """) 130 | self.assertTrue(test_function(missing_re_output, expect_commit=True) 131 | is None) 132 | 133 | # This is a successful commit. 134 | warning_output = textwrap.dedent("""\ 135 | [edit] 136 | Entering configuration mode 137 | 'interfaces' 138 | warning: statement has no contents; ignored 139 | 140 | load complete 141 | commit complete 142 | Exiting configuration mode 143 | """) 144 | self.assertIsNone( 145 | test_function(warning_output, expect_config_check=False, 146 | expect_commit=True)) 147 | # Also a successful commit from a switch-type device. 148 | output = textwrap.dedent("""\ 149 | Entering configuration mode 150 | |load complete 151 | configuration check succeedscommit complete 152 | Exiting configuration mode 153 | """) 154 | self.assertIsNone( 155 | test_function(output, expect_config_check=True, expect_commit=True)) 156 | 157 | # Check throwing cases. 158 | self.assertRaises( 159 | exceptions.SetConfigSyntaxError, 160 | test_function, 'foo\n syntax error: ') 161 | self.assertRaises( 162 | exceptions.SetConfigError, 163 | test_function, ' load failed (1 errors)') 164 | self.assertRaises( 165 | exceptions.SetConfigError, 166 | test_function, ' load complete (1 errors)') 167 | self.assertRaises( 168 | exceptions.SetConfigError, 169 | test_function, '[edit ...]\n syntax error\nerror: foo') 170 | self.assertRaises( 171 | exceptions.SetConfigError, 172 | test_function, 'error: configuration check-out failed') 173 | self.assertRaises( 174 | exceptions.SetConfigSyntaxError, 175 | test_function, 'syntax error: "connect to re1 :"') 176 | self.assertRaises( 177 | exceptions.SetConfigSyntaxError, 178 | test_function, 'syntax error: connect to re1 :') 179 | # Check all JUNOS_LOAD_ERRORS strings 180 | for error in self.device.JUNOS_LOAD_ERRORS: 181 | self.assertRaises(exceptions.SetConfigError, 182 | test_function, error) 183 | self.assertRaises(exceptions.SetConfigError, 184 | test_function, error, expect_commit=True) 185 | # Check the commit_check parameter. 186 | self.assertRaises( 187 | exceptions.SetConfigSyntaxError, 188 | test_function, ' load failed (1 errors)', expect_config_check=True) 189 | self.assertRaises( 190 | exceptions.SetConfigSyntaxError, 191 | test_function, 'error:\nsyntax error', expect_config_check=True) 192 | self.assertRaises( 193 | exceptions.SetConfigError, 194 | test_function, 'configuration check succeeds\n(1 errors)', 195 | expect_config_check=True) 196 | self.assertRaises( 197 | exceptions.SetConfigSyntaxError, 198 | test_function, '\'configuration check succeeds\'\nerror:', 199 | expect_config_check=True) 200 | # Check that we don't raise a syntax error just because someone wrote 201 | # "syntax error" in a description. 202 | self.assertRaises( 203 | exceptions.SetConfigError, 204 | test_function, '+description "syntax error";\nerror:') 205 | syntax_error_with_missing_re = textwrap.dedent(""" 206 | Entering configuration mode 207 | Users currently editing the configuration: 208 | netops terminal p3 (pid 44448) on since 2012-09-05 10:00:49 PDT, ... 209 | [edit] 210 | netops terminal p4 (pid 63408) on since 2012-09-16 23:57:00 PDT, ... 211 | private [edit] 212 | |\x08tmpPjACa3:1:(10) syntax error: deactivate 213 | load complete (1 errors) 214 | 215 | error: Could not connect to re1 : No route to host 216 | warning: Cannot connect to other RE, ignoring it 217 | commit complete 218 | Exiting configuration mode 219 | """) 220 | self.assertRaises(exceptions.SetConfigSyntaxError, 221 | test_function, syntax_error_with_missing_re, 222 | expect_config_check=False, 223 | expect_commit=False) 224 | # Also do the test with commit_check=True 225 | self.assertRaises(exceptions.SetConfigSyntaxError, 226 | test_function, syntax_error_with_missing_re, 227 | expect_config_check=False, expect_commit=True) 228 | failed_commit_b_9750034 = textwrap.dedent("""\ 229 | re0: 230 | error: Could not connect to re1 : No route to host 231 | 232 | [edit] 233 | """) 234 | self.assertRaises( 235 | exceptions.SetConfigError, 236 | test_function, failed_commit_b_9750034, expect_config_check=False, 237 | expect_commit=True) 238 | 239 | 240 | if __name__ == '__main__': 241 | unittest.main() 242 | -------------------------------------------------------------------------------- /paramiko_device.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # 3 | # Copyright 2014 Google Inc. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | """An abstract Paramiko SSH2 capable device model. 17 | 18 | For devices that can use Paramiko SSH2, this device defines the connection 19 | setup and teardown mechanisms. When sub-classing, you must define all API 20 | methods you wish to implement. Others will return an NotImplemented Exception. 21 | """ 22 | 23 | import time 24 | from absl import flags as gflags 25 | import logging 26 | 27 | import paramiko 28 | 29 | import sshclient 30 | import base_device 31 | import push_exceptions as exceptions 32 | 33 | 34 | FLAGS = gflags.FLAGS 35 | 36 | # Remote channel ids greater than this trigger a reconnect. The higher this 37 | # number, the more channels can be 'in flight' in a single session. 38 | _LOW_CHANID_THRESHOLD = 1 39 | 40 | 41 | class ParamikoDevice(base_device.BaseDevice): 42 | """A device model suitable for devices which support paramiko SSHv2. 43 | 44 | See the base_device.BaseDevice docstrings. 45 | """ 46 | 47 | def __init__(self, **kwargs): 48 | super(ParamikoDevice, self).__init__(**kwargs) 49 | # Setup local state. 50 | self._ssh_client = None 51 | self._port = kwargs.get('port', 22) 52 | self._username = None 53 | self._password = None 54 | 55 | def _Connect(self, username, password=None, ssh_keys=None, 56 | enable_password=None, ssl_cert_set=None): 57 | _ = ssl_cert_set 58 | logging.debug('In Paramiko._Connect, host is %s, self._connected? %s' 59 | '_ssh_client is None? %s', self.host, self._connected, 60 | self._ssh_client is None) 61 | self._username = username 62 | self._password = password or self._password 63 | self._ssh_keys = ssh_keys or self._ssh_keys or () 64 | self._enable_password = enable_password or self._enable_password 65 | 66 | self._ssh_client = sshclient.Connect(hostname=self.loopback_ipv4, 67 | username=self._username, 68 | password=self._password, 69 | port=self._port, 70 | ssh_keys=self._ssh_keys) 71 | return None 72 | 73 | def _GetConnected(self): 74 | """Sanity-checks the connected status prior to returning it. 75 | 76 | Returns: 77 | A bool, the connected status. 78 | """ 79 | logging.debug('In ParamikoDevice._GetConnected, host is %s (?)', self.host) 80 | if self._connected: 81 | if (self._ssh_client is None or 82 | self._ssh_client.get_transport() is None or 83 | not self._ssh_client.get_transport().is_active()): 84 | self._connected = False 85 | return self._connected 86 | 87 | def _Disconnect(self): 88 | logging.debug('In ParamikoDevice._Disconnect, host is %s, ' 89 | 'connected is %s, self._ssh_client is None? %s', 90 | self.host, self._connected, self._ssh_client is None) 91 | if self.connected and self._ssh_client is not None: 92 | self._ssh_client.close() 93 | self._ssh_client = None 94 | return None 95 | 96 | def _Cmd(self, command, mode=None, merge_stderr_first=False, send=None, 97 | require_low_chanid=False): 98 | response = '' 99 | retries_left = 1 100 | while True: 101 | try: 102 | chan = self._ssh_client.get_transport().open_session() 103 | chan.settimeout(self.timeout_response) 104 | if require_low_chanid and chan.remote_chanid > _LOW_CHANID_THRESHOLD: 105 | # We should not be having multiple channels open. If we do, 106 | # close them before proceeding. 107 | logging.error( 108 | 'Remote ssh channel id %d exceeded %d when opening session to ' 109 | '%s(%s), reconnecting.', 110 | chan.remote_chanid, _LOW_CHANID_THRESHOLD, self.host, 111 | self.loopback_ipv4) 112 | self.Disconnect() 113 | self.Connect(self._username, self._password, self._ssh_keys, 114 | self._enable_password) 115 | chan = self._ssh_client.get_transport().open_session() 116 | chan.exec_command(command) 117 | stdin = chan.makefile('wb', -1) 118 | stdout = chan.makefile('rb', -1) 119 | stderr = chan.makefile_stderr('rb', -1) 120 | if send is not None: 121 | stdin.write(send) 122 | stdout_data = stdout.read() 123 | stderr_data = stderr.read() 124 | 125 | # Request channel close by remote peer. 126 | chan.close() 127 | break 128 | except paramiko.SSHException as e: 129 | msg = str(e) 130 | logging.error('%s(%s) Cmd(%r, mode=%r): %s', self.host, 131 | self.loopback_ipv4, command, mode, msg) 132 | raise exceptions.CmdError(msg) 133 | except AttributeError: 134 | # This occurs when self._ssh_client becomes None after a Paramiko 135 | # failure. Pause momentarily, try to reconnect and loop to resend 136 | # the command. 137 | time.sleep(0.25) 138 | try: 139 | if retries_left: 140 | self._Connect(self._username, self._password, self._ssh_keys) 141 | retries_left -= 1 142 | continue 143 | else: 144 | raise exceptions.CmdError('Failed to exec_command after retry.') 145 | except paramiko.SSHException as e: 146 | msg = str(e) 147 | logging.error('%s(%s) Cmd(%r, mode=%r): %s', self.host, 148 | self.loopback_ipv4, command, mode, msg) 149 | raise exceptions.ConnectError(msg) 150 | except Exception as e: 151 | # Paramiko may raise any exception, so catch and log it here. 152 | msg = '%s:%s(%s) Cmd(%r, mode=%r): %s: %s' % ( 153 | type(e), self.host, self.loopback_ipv4, command, mode, 154 | e.__class__.__name__, str(e)) 155 | logging.exception(msg) 156 | raise exceptions.CmdError('%s: %s' % (e.__class__.__name__, str(e))) 157 | 158 | # Remove stderr lines started with 'waiting for'. 159 | if stderr_data and not merge_stderr_first: 160 | out = [] 161 | for l in stderr_data.splitlines(): 162 | if not l.startswith('waiting for'): 163 | out.append(l) 164 | stderr_data = '\n'.join(out) 165 | 166 | # Marshal the response from the stdout/err channels and handle errors. 167 | if stderr_data and not merge_stderr_first: 168 | raise exceptions.CmdError(stderr_data) 169 | elif stdout_data: 170 | if merge_stderr_first and stderr_data: 171 | response = stderr_data 172 | response += stdout_data 173 | else: 174 | # Sometimes, a command (e.g., 'show system license keys') returns 175 | # nothing. This can mean that the channel went away on us, and we 176 | # got no data back (and no error). 177 | if self.connected: 178 | logging.warn('Both STDOUT and STDERR empty after %s on %s(%s)', 179 | repr(command), self.host, self.loopback_ipv4) 180 | else: 181 | raise exceptions.CmdError('Connection to %s(%s) was terminated.' % 182 | (self.host, self.loopback_ipv4)) 183 | return response 184 | -------------------------------------------------------------------------------- /paramiko_device_test.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # 3 | # Copyright 2014 Google Inc. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | """Tests for paramiko_device.""" 17 | 18 | import cStringIO as StringIO 19 | import push_exceptions as exceptions 20 | from absl import flags as gflags 21 | import mox 22 | import paramiko_device 23 | import time 24 | import unittest 25 | 26 | FLAGS = gflags.FLAGS 27 | 28 | 29 | def FakeSshLibrary(stderr='', expected_command=''): 30 | """Creates a simple fake SSH connection.""" 31 | # pylint:disable=g-bad-name 32 | 33 | class FakeSshClient(object): 34 | 35 | def __init__(self, *unused_args, **unused_kwargs): 36 | self._channels = {} 37 | 38 | def close(self): 39 | self._closed = True 40 | 41 | def get_transport(self): 42 | return self 43 | 44 | def open_session(self): 45 | return self 46 | 47 | def settimeout(self, unused_timeout): 48 | pass 49 | 50 | def exec_command(self, command): 51 | assert command == expected_command, ( 52 | 'exec_command(%r) expected, got exec_command(%r)' % ( 53 | expected_command, command)) 54 | 55 | def makefile(self, unused_mode, unused_arg): 56 | return StringIO.StringIO() 57 | 58 | def makefile_stderr(self, unused_mode, unused_arg): 59 | return StringIO.StringIO(stderr) 60 | 61 | return FakeSshClient() 62 | 63 | 64 | class ParamikoDeviceTest(unittest.TestCase): 65 | 66 | def setUp(self): 67 | self._mox = mox.Mox() 68 | self._mox.StubOutWithMock(time, 'sleep') 69 | self.user = 'joe' 70 | self.pw = 'pass' 71 | 72 | def tearDown(self): 73 | self._mox.UnsetStubs() 74 | self._mox.VerifyAll() 75 | 76 | def testCommandSuccess(self): 77 | self._mox.StubOutWithMock(paramiko_device.sshclient, 'Connect') 78 | device = paramiko_device.ParamikoDevice() 79 | device.host = '127.0.0.1' 80 | device.loopback_ipv4 = '127.0.0.1' 81 | paramiko_device.sshclient.Connect( 82 | hostname=device.host, password=self.pw, port=22, ssh_keys=(), 83 | username=self.user).AndReturn( 84 | FakeSshLibrary(stderr='', expected_command='show version')) 85 | self._mox.ReplayAll() 86 | 87 | device.Connect(username=self.user, password=self.pw) 88 | device.Cmd('show version') 89 | 90 | def testCommandError(self): 91 | self._mox.StubOutWithMock(paramiko_device.sshclient, 'Connect') 92 | device = paramiko_device.ParamikoDevice() 93 | device.host = '127.0.0.1' 94 | device.loopback_ipv4 = '127.0.0.1' 95 | paramiko_device.sshclient.Connect( 96 | hostname=device.host, password=self.pw, port=22, ssh_keys=(), 97 | username=self.user).AndReturn( 98 | FakeSshLibrary(stderr='failboat', expected_command='show version')) 99 | self._mox.ReplayAll() 100 | 101 | device.Connect(username=self.user, password=self.pw) 102 | self.assertRaises(exceptions.CmdError, device.Cmd, 'show version') 103 | 104 | 105 | if __name__ == '__main__': 106 | unittest.main() 107 | -------------------------------------------------------------------------------- /pexpect_connection.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # 3 | # Copyright 2014 Google Inc. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | """Connections via pexpect to SSH and Telnet endpoints. 17 | 18 | By deliberate side-effect, this module overwrites pexpect.spawn.__select 19 | with an implementation based on poll(), to support use with higher file 20 | descriptors than supported by select(). 21 | """ 22 | 23 | import errno 24 | import os 25 | import re 26 | import select 27 | import socket 28 | import time 29 | 30 | #import paramiko 31 | from absl import flags as gflags 32 | import pexpect 33 | 34 | from absl import flags as gflags 35 | import logging 36 | 37 | import sshclient 38 | import push_exceptions as exceptions 39 | 40 | FLAGS = gflags.FLAGS 41 | 42 | TIMEOUT_DEFAULT = 20.0 43 | 44 | 45 | class Error(Exception): 46 | pass 47 | 48 | 49 | class ConnectionError(Error): 50 | """The connection failed due to an error.""" 51 | 52 | 53 | class TimeoutError(Error): 54 | """The operation timed-out.""" 55 | 56 | 57 | class OperationFailedError(Error): 58 | """The sub-process had a non-zero exit status.""" 59 | 60 | 61 | class ScpError(Error): 62 | """An error occurred during an SCP operation.""" 63 | 64 | 65 | def _SelectViaPoll(_, rfds, wfds, efds, timeout): 66 | """poll() based replacement for pexpect.spawn.__select(). 67 | 68 | As mentioned in the module docstring, this is required since Python's select 69 | is unable to wait for events on high-numbered file descriptors. The API is 70 | as per select.select(), however if we are interrupted by a signal, we wait 71 | again for the remaining time. 72 | 73 | Args: 74 | _: An object, self, unused. 75 | rfds: A list, file descriptors to check for read. 76 | wfds: A list, file descriptors to check for write. 77 | efds: A list, file descriptors to check for exceptions. 78 | timeout: A float, timeout (seconds). 79 | 80 | Returns: 81 | A tuple of three lists, being the descriptors in each of the incoming lists 82 | which are ready for read, write or have an exception, respectively. 83 | """ 84 | if wfds or efds: 85 | logging.fatal('Unexpected code change in pexpect: __select ' 86 | 'called with wfds=%s efds=%s', wfds, efds) 87 | p = select.poll() 88 | for fd in rfds: 89 | p.register(fd, select.POLLIN) 90 | 91 | # See pexpect.spawn.__select for timeout handling logic; this is the same 92 | # in select() and poll(), except that the timeout argument to poll() is 93 | # in milliseconds. poll() raises the same exception on timeout as select(). 94 | if timeout is not None: 95 | end_time = time.time() + timeout 96 | while True: 97 | try: 98 | fdstate = p.poll(int(timeout * 1000) if timeout is not None else None) 99 | # Build a list of descriptors which select() would return as 'available 100 | # for read' (which includes EOF conditions which may be indicated as 101 | # POLLIN, POLLHUP or POLLIN|POLLHUP, depending on the type of file 102 | # descriptor). 103 | rrfds = [] 104 | for fd, state in fdstate: 105 | if state & select.POLLIN or state & select.POLLHUP: 106 | rrfds.append(fd) 107 | return (rrfds, [], []) 108 | except select.error as e: 109 | if e[0] == errno.EINTR: 110 | if timeout is not None: 111 | timeout = end_time - time.time() 112 | if timeout < 0: 113 | return ([], [], []) 114 | else: 115 | raise 116 | 117 | # Override pexpect.spawn.__select as mentioned in module docstring. 118 | pexpect.spawn._spawn__select = _SelectViaPoll 119 | 120 | 121 | class Connection(object): 122 | """The base class for pexpect connections.""" 123 | 124 | def __init__(self, host, username, password=None, success=None, 125 | connect_command=None, timeout=None, find_prompt=False, 126 | enable_password=None, find_prompt_prefix=None, 127 | find_prompt_suffix=''): 128 | """Initializer. 129 | 130 | Args: 131 | host: A string, the hostname or IP address to connect to. 132 | username: A string, the username to use on the connection. 133 | password: A string, the password to use on the connection. 134 | success: A string, the string to expect to trigger successful completion. 135 | connect_command: A string, the command to connect (minus the host suffix). 136 | timeout: A float, the number of seconds before a connection times out. 137 | find_prompt: A bool, if true then success is a regexp and it's group(1) 138 | should be used to build self._prompt. 139 | enable_password: A string, the enable password to optionally use. 140 | find_prompt_prefix: A string, the prefix to put before group(1) from the 141 | success regexp to build self._prompt, if find_prompt is true. 142 | """ 143 | self._connect_timeout = timeout or TIMEOUT_DEFAULT 144 | self._host = host 145 | self._username = username 146 | self._password = password 147 | self._success = success 148 | self._find_prompt = find_prompt 149 | self._connect_command = connect_command 150 | self._enable_password = enable_password 151 | self._find_prompt_prefix = ( 152 | r'(?:^|\n)' if find_prompt_prefix is None else find_prompt_prefix) 153 | self._find_prompt_suffix = find_prompt_suffix 154 | self.child = None 155 | 156 | def _MaybeFindPrompt(self): 157 | """Enable if necessary, then perform prompt discovery if required.""" 158 | if self._enable_password: 159 | # Enable before prompt discovery. Set a broad prompt expression. 160 | password_sent = False 161 | logging.debug('Enabling on %r', self._host) 162 | self.child.sendline('enable') 163 | while True: 164 | i = self.child.expect( 165 | [self._success, 'Password:', 'Bad secrets', 166 | 'Password: timeout expired!'], timeout=10) 167 | if i == 0: 168 | # Found the prompt, we're enabled. 169 | logging.debug('We are enabled') 170 | break 171 | elif i == 1 and not password_sent: 172 | self.child.sendline(self._enable_password) 173 | logging.debug('Sent enable password to %r', self._host) 174 | password_sent = True 175 | else: 176 | logging.debug('Got index %d back from expect', i) 177 | # Sleep momentarily before expecting again, break buffer swap races. 178 | time.sleep(0.05) 179 | 180 | if self._find_prompt: 181 | host = re.escape(self.child.match.group(1)) 182 | if len(self.child.match.groups()) > 1: 183 | mode = re.escape(self.child.match.group(2)) 184 | else: 185 | mode = '' 186 | try: 187 | self._prompt = ( 188 | self._find_prompt_prefix + host + self._find_prompt_suffix + mode) 189 | self.re_prompt = re.compile(self._prompt) 190 | logging.debug('%s: prompt set to %r', self._host, self._prompt) 191 | except IndexError: 192 | logging.debug('%s: find_prompt set but no capture group - skipping', 193 | self._host) 194 | 195 | else: 196 | self.re_prompt = re.compile(self._success) 197 | 198 | 199 | 200 | class SocketSpawn(pexpect.spawn): 201 | """Wrapper around pexpect.spawn to use a supplied socket. 202 | 203 | This class does not close the file; it assumes it is a Python socket 204 | which will be held/destroyed by the caller. 205 | """ 206 | # pylint: disable=g-bad-name 207 | 208 | def __init__(self, sock, *args, **kwargs): 209 | pexpect.spawn.__init__(self, None, *args, **kwargs) 210 | self.child_fd = sock.fileno() 211 | self.closed = False 212 | self.name = '' % self.child_fd 213 | 214 | def isalive(self): 215 | if self.child_fd == -1: 216 | return False 217 | try: 218 | os.fstat(self.child_fd) 219 | return True 220 | except OSError: 221 | return False 222 | 223 | def __del__(self): 224 | return 225 | 226 | def close(self): 227 | return 228 | 229 | def terminate(self, force=False): 230 | _ = force 231 | return 232 | 233 | def kill(self, sig): 234 | _ = sig 235 | return 236 | 237 | 238 | class SocketConnection(Connection): 239 | """IPv4 TCP socket connection class.""" 240 | 241 | def __init__(self, host, port, username, password=None, success=None, 242 | timeout=None, initial_chat=None, find_prompt=False, 243 | find_prompt_prefix=None): 244 | """Creates an IPv4 TCP socket connection. 245 | 246 | Args: 247 | host: As per parent. 248 | port: An int, the port number to connect to. 249 | username: As per parent. 250 | password: As per parent. 251 | success: As per parent. 252 | timeout: As per parent. 253 | initial_chat: A tuple of tuples, each tuple in this list is a string 254 | to expect from the socket and a response; the chat must occur in the 255 | exact order specified. Intended only for telnet option negotiation. 256 | find_prompt: As per parent. 257 | find_prompt_prefix: As per parent. 258 | """ 259 | super(SocketConnection, self).__init__( 260 | host, username=username, password=password, success=success, 261 | timeout=timeout, find_prompt=find_prompt, 262 | find_prompt_prefix=find_prompt_prefix) 263 | self._port = port 264 | self._initial_chat = initial_chat 265 | self._connect_timeout = timeout or TIMEOUT_DEFAULT 266 | if success is None: 267 | self._success = self._username+r'.*> ' 268 | 269 | def Connect(self): 270 | """Makes the connection.""" 271 | self._sock = socket.socket() 272 | self._sock.settimeout(self._connect_timeout) 273 | try: 274 | self._sock.connect((self._host, self._port)) 275 | except socket.timeout: 276 | raise TimeoutError(self._connect_timeout) 277 | except socket.gaierror as e: 278 | raise ConnectionError('Lookup failure for %r: %s' % (self._host, e[1])) 279 | except socket.error as e: 280 | raise ConnectionError('Connect failure for %r: %s' % (self._host, e[1])) 281 | 282 | if self._initial_chat is not None: 283 | try: 284 | for expected_recv, to_send in self._initial_chat: 285 | actual_recv = self._sock.recv(len(expected_recv)) 286 | if actual_recv == expected_recv: 287 | self._sock.send(to_send) 288 | else: 289 | raise ConnectionError('Initial chat failure for %r: expected %r, ' 290 | 'got %r' % (self._host, expected_recv, 291 | actual_recv)) 292 | except socket.timeout: 293 | logging.debug('Initial chat timeout for %r', self._host) 294 | raise TimeoutError(self._connect_timeout) 295 | 296 | self._sock.settimeout(None) 297 | self.child = SocketSpawn(self._sock, maxread=8192) 298 | self.child.timeout = self._connect_timeout 299 | logging.debug('Socket connected to %r:%s', self._host, self._port) 300 | 301 | responses = self.child.compile_pattern_list([ 302 | self._success, 303 | r'[Ll]ogin|[Uu]ser[Nn]ame', 304 | r'[Pp]assword:', 305 | r'Permission denied|Authentication failed']) 306 | self.exit_list = self.child.compile_pattern_list(pexpect.EOF) 307 | 308 | while True: 309 | try: 310 | timeout = max(1, self._connect_timeout) 311 | pattern = self.child.expect_list(responses, timeout=timeout) 312 | logging.debug('Connect() matched responses[%d]', pattern) 313 | if pattern == 0: 314 | self._MaybeFindPrompt() 315 | break 316 | elif pattern == 1: 317 | self.child.send(self._username+'\r') 318 | elif pattern == 2: 319 | self.child.send(self._password+'\r') 320 | elif pattern == 3: 321 | raise ConnectionError('Permission denied for %r' % self._host) 322 | else: 323 | raise ConnectionError('Unexpected pattern %d' % pattern) 324 | except pexpect.TIMEOUT: 325 | raise TimeoutError(timeout) 326 | except pexpect.EOF as e: 327 | raise ConnectionError(str(e)) 328 | return None 329 | 330 | 331 | class SshSpawn(pexpect.spawn): 332 | """Wrapper around pexpect.spawn to use a Paramiko channel.""" 333 | # pylint: disable=g-bad-name 334 | 335 | def __init__(self, channel, *args, **kwargs): 336 | pexpect.spawn.__init__(self, None, *args, **kwargs) 337 | self.channel = channel 338 | self.child_fd = None 339 | self.closed = False 340 | self.name = '' % channel.get_id() 341 | 342 | def isalive(self): 343 | try: 344 | return self.channel.get_transport().is_active() 345 | except AttributeError: 346 | return False 347 | 348 | def read_nonblocking(self, size=1, timeout=None): 349 | """See parent. This actually may or may not block based on timeout.""" 350 | if not self.isalive(): 351 | raise pexpect.EOF('End Of File (EOF) in read() - Not alive.') 352 | 353 | if timeout == -1: 354 | timeout = self.timeout 355 | 356 | self.channel.settimeout(timeout) 357 | try: 358 | s = self.channel.recv(size) 359 | except socket.timeout: 360 | raise pexpect.TIMEOUT('Timeout (%s) exceeded in read().' % timeout) 361 | except paramiko.SSHException as e: 362 | raise pexpect.EOF('Paramiko exception: %s' % e) 363 | except EOFError: 364 | raise pexpect.EOF('Paramiko reported End Of File (EOF) in read()') 365 | if not s: 366 | self.flag_eof = 1 367 | raise pexpect.EOF('End Of File (EOF) in read().') 368 | return s 369 | 370 | def send(self, s): 371 | return self.channel.send(s) 372 | 373 | def __del__(self): 374 | return 375 | 376 | def close(self): 377 | return 378 | 379 | def terminate(self, force=False): 380 | _ = force 381 | return 382 | 383 | def kill(self, sig): 384 | _ = sig 385 | return 386 | 387 | 388 | class HpSshSpawn(SshSpawn): 389 | """Wrapped pexpect.spawn to use a Paramiko channel and HP ANSI filters. 390 | 391 | This also deals with the annoying pager which cannot be disabled. 392 | """ 393 | # ANSI character sequences to convert to a newline. 394 | NEWLINE_RE = re.compile('\x1B(?:\\[0m|E)') 395 | 396 | # All other ANSI character sequences (removed from the output). 397 | # Matches all strings containing \x1B, unless they contain a truncated ANSI 398 | # sequence at the end of the string. 399 | ANSI_RE = re.compile('\x1B([^[]|\\[[^@-~]*[@-~])') 400 | 401 | def __init__(self, channel, *args, **kwargs): 402 | SshSpawn.__init__(self, channel, *args, **kwargs) 403 | self._read_nonblocking_buf = '' 404 | 405 | def _Filter(self, text): 406 | text = re.sub(self.NEWLINE_RE, '\n', text) 407 | text = re.sub(self.ANSI_RE, '', text) 408 | logging.debug('Filtered: %r', text) 409 | return text 410 | 411 | def read_nonblocking(self, size=1, timeout=None): 412 | """Read, handling terminal control input from an HP ProCurve. 413 | 414 | This may or may not actually block, as per its parent. 415 | 416 | Args: 417 | size: An int, the minimum size block to return. 418 | timeout: An optional float, wait only timeout seconds at most. 419 | 420 | Returns: 421 | A string, the filtered output. 422 | """ 423 | start = time.time() 424 | if timeout == -1: 425 | timeout = self.timeout 426 | while True: 427 | if timeout and time.time() > start + timeout: 428 | return '' 429 | logging.debug('Unfiltered: %r', in_data) 430 | if in_data and self._read_nonblocking_buf: 431 | logging.debug('Prepending data: %r', self._read_nonblocking_buf) 432 | in_data = self._read_nonblocking_buf + in_data 433 | self._read_nonblocking_buf = '' 434 | filtered = self._Filter(in_data) 435 | escape_location = filtered.find('\x1B') 436 | if escape_location != -1: 437 | logging.debug('Partial ANSI tag in filtered data: %r', filtered) 438 | self._read_nonblocking_buf = filtered[escape_location:] 439 | filtered = filtered[:escape_location] 440 | if filtered: 441 | return filtered 442 | 443 | 444 | class ParamikoSshConnection(Connection): 445 | """Base class for SSH connections using Paramiko.""" 446 | 447 | def __init__(self, host, username, password=None, success=None, 448 | timeout=None, find_prompt=False, ssh_keys=None, 449 | enable_password=None, ssh_client=None, find_prompt_prefix=None): 450 | """Initializer. 451 | 452 | Args: 453 | host: As per parent. 454 | username: As per parent. 455 | password: As per parent. 456 | success: As per parent. 457 | timeout: As per parent. 458 | find_prompt: As per parent. 459 | ssh_keys: A tuple of strings, SSH private keys (optional; may be None). 460 | enable_password: As per parent. 461 | ssh_client: A instance of an object that implements an SSH client. 462 | find_prompt_prefix: As per parent. 463 | """ 464 | super(ParamikoSshConnection, self).__init__( 465 | host, username, password, success, None, timeout, find_prompt, 466 | enable_password=enable_password, find_prompt_prefix=find_prompt_prefix) 467 | if success is None: 468 | self._success = self._username+r'.*> ' 469 | self.ssh_client = ssh_client 470 | self._ssh_client = None 471 | self._ssh_keys = ssh_keys or () 472 | self._spawn = SshSpawn 473 | if self._spawn is None: 474 | raise NotImplementedError('Must supply a spawn= keywoard argument.') 475 | 476 | def Connect(self): 477 | """Makes the connection. 478 | 479 | We can have an instance of this class without being connected to the 480 | device, e.g. after a disconnect. Hence setting up the actual SSH connection 481 | should happen in this method, not in the constructor. 482 | """ 483 | try: 484 | if self.ssh_client: 485 | # An SSH client was provided. Use it. 486 | self._ssh_client = self.ssh_client.Connect( 487 | hostname=self._host, 488 | username=self._username, 489 | password=self._password, 490 | ssh_keys=self._ssh_keys, 491 | timeout=self._connect_timeout) 492 | else: 493 | # The Connect() function from the sshclient module is a factory that 494 | # returns a paramiko.SSHClient instance. 495 | self._ssh_client = sshclient.Connect( 496 | hostname=self._host, 497 | username=self._username, 498 | password=self._password, 499 | ssh_keys=self._ssh_keys, 500 | timeout=self._connect_timeout) 501 | except (exceptions.ConnectError, exceptions.AuthenticationError) as e: 502 | raise ConnectionError(str(e)) 503 | # We are connected. Now set up pexpect. 504 | logging.debug('SETTING UP PEXPECT') 505 | try: 506 | ssh_channel = self._ssh_client.invoke_shell() 507 | logging.debug('INVOKED A SHELL') 508 | ssh_channel.set_combine_stderr(True) 509 | logging.debug('COMBINED STDERR') 510 | self.child = self._spawn(ssh_channel, maxread=8192) 511 | logging.debug('SPAWNED') 512 | timeout = max(1, self._connect_timeout) 513 | pattern = self.child.expect([self._success], timeout=timeout) 514 | logging.debug('GOT PATTERN: %s', pattern) 515 | if pattern == 0: 516 | self._MaybeFindPrompt() 517 | except pexpect.TIMEOUT: 518 | raise TimeoutError(timeout) 519 | except pexpect.EOF as e: 520 | raise ConnectionError(str(e)) 521 | except paramiko.SSHException as e: 522 | msg = 'SSHException connecting to %r: %s' % (self._host, e) 523 | raise ConnectionError(msg) 524 | 525 | # Used by _Disconnect in ftos.py and ios.py. 526 | self.exit_list = self.child.compile_pattern_list(pexpect.EOF) 527 | return None 528 | 529 | 530 | class HpSshFilterConnection(ParamikoSshConnection): 531 | """Creates an SSH connection to an HP Switch with terminal escape filtering. 532 | 533 | This filters terminal escape sequences seen on the Hewlett-Packard ProCurve 534 | ethernet switches. 535 | """ 536 | 537 | def __init__(self, host, username, password=None, success=None, 538 | timeout=None, find_prompt=False, ssh_keys=None, 539 | enable_password=None, ssh_client=None, find_prompt_prefix=None): 540 | super(HpSshFilterConnection, self).__init__( 541 | host, username, password, success, timeout, find_prompt, 542 | ssh_keys=ssh_keys, enable_password=enable_password, 543 | ssh_client=ssh_client, find_prompt_prefix=find_prompt_prefix) 544 | self._spawn = HpSshSpawn 545 | 546 | def _MaybeFindPrompt(self): 547 | """Perform real login and then enable if we have an enable password.""" 548 | # We always run this for HP, no matter the state of self._find_prompt. 549 | self._prompt = r'(?:^|\n|\r)([A-Za-z0-9\._-]+)(?:>|#) ' 550 | # Shake out the prompt. We may be facing a Password prompt or 551 | # a 'Press any key to continue' prompt. 552 | self.child.send('\r') 553 | 554 | # Only send the password once. 555 | password_sent = False 556 | try: 557 | # Login. 558 | while True: 559 | logging.debug('Expecting prompt %r', self._prompt) 560 | compiled_regexes = self.child.compile_pattern_list( 561 | [self._prompt, r'Press any key to continue', 562 | 'Password:', 'Invalid password', 563 | 'Unable to verify password']) 564 | i = self.child.expect(compiled_regexes, timeout=10) 565 | if i == 0: 566 | re_str = (re.escape(self.child.match.group(1)) + 567 | r'(?:>|#) ') 568 | logging.debug('Prompt set to %r', re_str) 569 | self.re_prompt = re.compile(re_str) 570 | break 571 | elif i == 1: 572 | logging.debug('Pressing any key (space)') 573 | self.child.send(' ') 574 | elif i == 2 and not password_sent: 575 | # Send the password only once. 576 | try: 577 | self.child.sendline(self._password) 578 | logging.debug('Sent user password (again) to %r', self._host) 579 | password_sent = True 580 | except (pexpect.TIMEOUT, pexpect.EOF) as e: 581 | self._ssh_client = None 582 | raise ConnectionError(str(e)) 583 | elif i <= 3 and i < 5: 584 | logging.error('CONNECT_ERROR Incorrect user password on %r', 585 | self._host) 586 | 587 | # Sleep momentarily before expecting again to break buffer swap races. 588 | time.sleep(0.05) 589 | 590 | # Enable. 591 | password_sent = False 592 | logging.debug('Enabling for HP on %r', self._host) 593 | self.child.sendline('enable') 594 | while True: 595 | i = self.child.expect([self._prompt, 'Password:', 596 | 'Invalid password', 597 | 'Unable to verify password'], timeout=10) 598 | if i == 0: 599 | # Found the prompt, we're enabled. 600 | break 601 | elif i == 1 and not password_sent: 602 | if self._enable_password is not None: 603 | self.child.sendline(self._enable_password) 604 | logging.debug('Sent enable password to %r', self._host) 605 | else: 606 | self.child.sendline(self._password) 607 | logging.debug('Sent user password to %r', self._host) 608 | password_sent = True 609 | elif i <= 3 and i < 5: 610 | logging.error('CONNECT_ERROR Incorrect user password on %r', 611 | self._host) 612 | # Sleep momentarily before expecting again to break buffer swap races. 613 | time.sleep(0.05) 614 | except (pexpect.TIMEOUT, pexpect.EOF) as e: 615 | self._ssh_client = None 616 | raise ConnectionError(str(e)) 617 | 618 | 619 | class ScpPutConnection(Connection): 620 | """Copies a file via SCP (RCP over SSH).""" 621 | 622 | def __init__(self, host, username, password=None): 623 | """Initializer. 624 | 625 | Args: 626 | host: As per parent. 627 | username: As per parent. 628 | password: As per parent. 629 | """ 630 | super(ScpPutConnection, self).__init__(host, username, password) 631 | self._ssh_client = sshclient.Connect(hostname=self._host, 632 | username=self._username, 633 | password=self._password) 634 | self.transport = self._ssh_client.get_transport() 635 | 636 | def Copy(self, source_data, destination_file): 637 | """Handles the SCP file copy. 638 | 639 | Args: 640 | source_data: The source data to copy as a string 641 | destination_file: The file on the remote device 642 | 643 | Raises: 644 | ScpError: There was an error copying the file. 645 | """ 646 | try: 647 | sshclient.ScpPut(self.transport, source_data, destination_file, 648 | self._connect_timeout) 649 | except sshclient.ScpError as e: 650 | raise ScpError('SCP put failed: %s: %s' % (e.__class__.__name__, e)) 651 | -------------------------------------------------------------------------------- /push.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # 3 | # Copyright 2014 Google Inc. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | """Distribute bits of configuration to network elements. 17 | 18 | Given some device names and configuration files (or a list of configuration 19 | files with names hinting at the target device) send the configuration to the 20 | target devices. These types of pushes can be IO bound, so threading is 21 | appropriate. 22 | """ 23 | 24 | import getpass 25 | import logging 26 | import os 27 | import Queue 28 | import socket 29 | import sys 30 | import threading 31 | 32 | from absl import app 33 | from absl import flags as gflags 34 | import progressbar 35 | 36 | # Eval is used for building vendor objects. 37 | # pylint: disable-msg=W0611 38 | import aruba 39 | import asa 40 | import brocade 41 | import cisconx 42 | import ciscoxr 43 | import hp 44 | import ios 45 | import junos 46 | import paramiko_device 47 | # pylint: enable-msg=W0611 48 | import push_exceptions as exceptions 49 | 50 | FLAGS = gflags.FLAGS 51 | 52 | gflags.DEFINE_list('targets', '', 'A comma separated list of target devices.', 53 | short_name='T') 54 | 55 | gflags.DEFINE_bool('canary', False, 56 | 'Do everything possible, save for applying the config.', 57 | short_name='c') 58 | 59 | gflags.DEFINE_bool('devices_from_filenames', False, 60 | 'Use the configuration file names to determine the target ' 61 | 'device.', short_name='d') 62 | 63 | gflags.DEFINE_bool('enable', False, 64 | 'Use if target devices require an enable password.', 65 | short_name='e') 66 | 67 | gflags.DEFINE_string('vendor', '', 'A vendor name. Must be one of the ' 68 | 'implementations in this directory', 69 | short_name='V') 70 | 71 | gflags.DEFINE_string('user', '', 'Username for logging into the devices. This ' 72 | 'will default to your own username.', 73 | short_name='u') 74 | 75 | gflags.DEFINE_string('command', '', 'Rather than a config file, you would like ' 76 | 'to issue a command and get a response.', 77 | short_name='C') 78 | 79 | gflags.DEFINE_string('suffix', '', 'Append suffix onto each target provided.', 80 | short_name='s') 81 | 82 | gflags.DEFINE_integer('threads', 20, 'Number of push worker threads.', 83 | short_name='t') 84 | 85 | 86 | FORMAT = "%(asctime)-15s:%(levelname)s:%(filename)s:%(module)s:%(lineno)d %(message)s" 87 | logging.basicConfig(format=FORMAT) 88 | logging.basicConfig(filename='/tmp/push.log') 89 | 90 | class Error(Exception): 91 | """Base exception class.""" 92 | 93 | 94 | class UsageError(Error): 95 | """Incorrect flags usage.""" 96 | 97 | 98 | class PushThread(threading.Thread): 99 | def __init__( self, task_queue, output_queue, error_queue, vendor_class, 100 | password, enable=None): 101 | """Initiator. 102 | 103 | Args: 104 | task_queue: Queue.Queue holding two-tuples (str, str); 105 | Resolvable device name or IP of the target, 106 | configuration or command. 107 | output_queue: Queue.Queue holding two-tuples (str, str); 108 | Resolvable device name or IP of the target, 109 | output from push. 110 | error_queue: Queue.Queue holding two-tuples (str, str); 111 | Resolvable device name or IP of the target, 112 | error string from caught exception. 113 | vendor_class: type; Vendor appropriate class to use for this push. 114 | password: str; Password to use for devices (username is set in FLAGS). 115 | enable: str; Optional enable password to use for devices. 116 | """ 117 | threading.Thread.__init__(self) 118 | self._task_queue = task_queue 119 | self._output_queue = output_queue 120 | self._error_queue = error_queue 121 | self._vendor_class = vendor_class 122 | self._password = password 123 | self._enable = enable 124 | 125 | 126 | def run(self): 127 | """Work on emptying the task queue.""" 128 | while not self._task_queue.empty(): 129 | target, command_or_config = self._task_queue.get() 130 | # This is a workaround. The base_device.BaseDevice class requires 131 | # loopback_ipv4 for ultimately passing on to sshclient.Connect - yet this 132 | # can be a hostname that resolves to a AAAA, kooky I know. 133 | device = self._vendor_class(host=target, loopback_ipv4=target) 134 | 135 | # Connect. 136 | try: 137 | device.Connect(username=FLAGS.user, password=self._password, 138 | enable_password=self._enable) 139 | except exceptions.ConnectError as e: 140 | self._error_queue.put((target, e)) 141 | continue 142 | 143 | # Send command or config. 144 | if FLAGS.command: 145 | response = device.Cmd(command=command_or_config) 146 | self._output_queue.put((target, response)) 147 | else: 148 | try: 149 | response = device.SetConfig( 150 | destination_file='running-config', data=command_or_config, 151 | canary=FLAGS.canary) 152 | except exceptions.SetConfigError as e: 153 | self._error_queue.put((target, e)) 154 | # If the config change attempt hits an error, bail out of the 155 | # threads here, otherwise the thread will exception below on 156 | # response.transcript, since response is undefined at this point. 157 | logging.warn('SetConfig failed for %s, exiting thread.', target) 158 | continue 159 | self._output_queue.put((target, response.transcript)) 160 | 161 | device.Disconnect() 162 | 163 | 164 | def JoinFiles(files): 165 | """Take a list of file names, read and join their content. 166 | 167 | Args: 168 | files: list; String filenames to open and read. 169 | Returns: 170 | str; The consolidated content of the provided filenames. 171 | """ 172 | configlet = '' 173 | for f in files: 174 | # Let IOErrors happen naturally. 175 | configlet = configlet + (open(f).read()) 176 | return configlet 177 | 178 | 179 | def CheckFlags(files, class_path): 180 | """Validates flag sanity. 181 | 182 | Args: 183 | files: list; from argv[1:] 184 | class_path: str; class path of a vendor, for use by eval. 185 | Returns: 186 | type: A vendor class. 187 | Raises: 188 | UsageError: on flag mistakes. 189 | """ 190 | # Flags "devices" and "devices_from_filenames" are mutually exclusive. 191 | if ((not FLAGS.targets and not FLAGS.devices_from_filenames) 192 | or (FLAGS.targets and FLAGS.devices_from_filenames)): 193 | raise UsageError( 194 | 'No targets defined, try --targets.') 195 | 196 | # User must provide a vendor. 197 | elif not FLAGS.vendor: 198 | raise UsageError( 199 | 'No vendor defined, try the --vendor flag (i.e. --vendor ios)') 200 | 201 | # We need some configuration files unless --command is used. 202 | elif not files and not FLAGS.command: 203 | raise UsageError( 204 | 'No configuration files provided. Provide these via argv / glob.') 205 | # Ensure the provided vendor is implemented. 206 | else: 207 | try: 208 | pusher = eval(class_path) 209 | except NameError: 210 | raise UsageError( 211 | 'The vendor "%s" is not implemented or imported. Please select a ' 212 | 'valid vendor' % FLAGS.vendor) 213 | return pusher 214 | 215 | 216 | def main(argv): 217 | """Check flags and start the threaded push.""" 218 | files = FLAGS(argv)[1:] 219 | 220 | # Vendor implementations must be named correctly, i.e. IosDevice. 221 | vendor_classname = FLAGS.vendor.lower().capitalize() + 'Device' 222 | class_path = '.'.join([FLAGS.vendor.lower(), vendor_classname]) 223 | 224 | pusher = CheckFlags(files, class_path) 225 | 226 | if not FLAGS.user: 227 | FLAGS.user = getpass.getuser() 228 | 229 | # Queues will hold two tuples, (device_string, config) and 230 | # (device_string, output) respectively. 231 | task_queue = Queue.Queue() 232 | output_queue = Queue.Queue() 233 | # Holds target strings of devices with connection errors. 234 | error_queue = Queue.Queue() 235 | 236 | # files is a slight misnomer, this is meant to catch length of 237 | # targets, if true, otherwise devices_from_filenames files. 238 | targets_or_files = FLAGS.targets or files 239 | 240 | # Build the mapping of target to configuration. 241 | if FLAGS.devices_from_filenames: 242 | for device_file in files: 243 | # JoinFiles provides consistent file contents gathering. 244 | task_queue.put( 245 | (os.path.basename(device_file) + FLAGS.suffix, 246 | JoinFiles([device_file]))) 247 | print 'Ready to push per-device configurations to %s' % targets_or_files 248 | else: 249 | print 'Ready to push %s to %s' % (files or FLAGS.command, FLAGS.targets) 250 | for device in FLAGS.targets: 251 | # Either the command string or consolidated config goes into the task 252 | # queue. The PushThread uses FLAGS.command to know if this is a command or 253 | # config to set. 254 | task_queue.put((device + FLAGS.suffix, FLAGS.command or JoinFiles(files))) 255 | 256 | # A password is only necessary if the ssh-agent is not to be used. 257 | if not FLAGS.use_ssh_agent: 258 | passw = getpass.getpass('Password:') 259 | else: 260 | passw = None 261 | 262 | # An enable password is only required if the user specifies the enable flag. 263 | if FLAGS.enable: 264 | en = getpass.getpass('Enable:') 265 | else: 266 | en = None 267 | 268 | threads = [] 269 | for _ in xrange(FLAGS.threads): 270 | worker = PushThread(task_queue, output_queue, error_queue, pusher, passw, en) 271 | threads.append(worker) 272 | worker.start() 273 | 274 | # Progress feedback. 275 | widgets = [ 276 | 'Pushing... ', progressbar.Percentage(), ' ', 277 | progressbar.Bar(marker=progressbar.RotatingMarker()), ' ', 278 | progressbar.ETA(), ' ', progressbar.FileTransferSpeed()] 279 | pbar = progressbar.ProgressBar( 280 | widgets=widgets, maxval=len(targets_or_files)).start() 281 | 282 | while not task_queue.empty(): 283 | pbar.update(len(targets_or_files) - task_queue.qsize()) 284 | pbar.finish() 285 | 286 | for worker in threads: 287 | worker.join() 288 | 289 | if FLAGS.command: 290 | while not output_queue.empty(): 291 | dev, out = output_queue.get() 292 | print '#!# %s:%s #!#\n\n%s' % (dev, FLAGS.command, out) 293 | 294 | failures = [] 295 | while not error_queue.empty(): 296 | failures.append(error_queue.get()) 297 | 298 | connect_fail = [ 299 | (x, y) for (x, y) in failures if isinstance(y, exceptions.ConnectError)] 300 | config_fail = [ 301 | (x, y) for (x, y) in failures if isinstance(y, exceptions.SetConfigError)] 302 | 303 | if connect_fail: 304 | print '\nFailed to connect to:\n%s\n' % ','.join( 305 | [x for x, _ in connect_fail]) 306 | if FLAGS.verbose: 307 | for device, error in connect_fail: 308 | print '#!# %s:ConnectError #!#\n%s' % (device, error) 309 | if config_fail: 310 | print '\nSetting config failed:\n%s\n' % ','.join( 311 | [x for x, _ in config_fail]) 312 | if FLAGS.verbose: 313 | for device, error in connect_fail: 314 | print '#!# %s:SetConfigError #!#\n%s' % (device, error) 315 | 316 | 317 | if __name__ == '__main__': 318 | app.run(main) 319 | -------------------------------------------------------------------------------- /push_exceptions.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # 3 | # Copyright 2014 Google Inc. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | """Exceptions raised by the push librarires.""" 17 | 18 | 19 | class Error(Exception): 20 | pass 21 | 22 | 23 | class ConnectError(Error): 24 | """Indicates a connection could not be established.""" 25 | 26 | 27 | class CmdError(Error): 28 | """An Error that occurred while executing a Cmd method.""" 29 | 30 | 31 | class GetConfigError(Error): 32 | """An Error that occurred inside GetConfig.""" 33 | 34 | 35 | class EmptyConfigError(GetConfigError): 36 | """An empty configuration was produced by the GetConfig command.""" 37 | 38 | 39 | class SetConfigError(Error): 40 | """An Error that occurred inside SetConfig.""" 41 | 42 | 43 | class SetConfigCanaryingError(Error): 44 | """The request to canary the configuration failed (probably not supported).""" 45 | 46 | 47 | class SetConfigSyntaxError(Error): 48 | """The device reported a configuration syntax error during SetConfig.""" 49 | 50 | 51 | class DisconnectError(Error): 52 | """An error occurred during device Disconnect.""" 53 | 54 | 55 | class AuthenticationError(Error): 56 | """The authentication details for the connection failed to gain access.""" 57 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from cx_Freeze import setup, Executable 2 | 3 | # Dependencies are automatically detected, but it might need 4 | # fine tuning. 5 | buildOptions = dict(packages = [], excludes = []) 6 | 7 | executables = [ 8 | Executable('push.py', 'Console', targetName = 'ldpush') 9 | ] 10 | 11 | setup(name='ldpush', 12 | version = '1.0', 13 | description = 'A cross-vendor network configuration distribution tool. This is useful for pushing ACLs or other pieces of configuration to network elements. It can also be used to send commands to a list of devices and gather the results.', 14 | options = dict(build_exe = buildOptions), 15 | executables = executables) 16 | -------------------------------------------------------------------------------- /sshclient.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # 3 | # Copyright 2014 Google Inc. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | """Commmon helper methods for creating an SSH connection.""" 17 | 18 | import cStringIO 19 | import push_exceptions as exceptions 20 | from absl import flags as gflags 21 | import logging 22 | import paramiko 23 | import socket 24 | import threading 25 | 26 | 27 | gflags.DEFINE_string('paramiko_ssh_config', 28 | '', 29 | 'Use this file to pass options using the same format as ' 30 | 'OpenSSH.') 31 | 32 | gflags.DEFINE_boolean('use_ssh_agent', 33 | False, 34 | 'Use a local ssh-agent to get key material for ' 35 | 'authentication attempts.') 36 | 37 | gflags.DEFINE_string('key', 38 | None, 39 | 'Use this specific key') 40 | 41 | FLAGS = gflags.FLAGS 42 | 43 | TIMEOUT_DEFAULT = 20.0 44 | 45 | 46 | class Error(Exception): 47 | pass 48 | 49 | 50 | class ScpError(Error): 51 | """An error occurred while attempting a SCP copy.""" 52 | 53 | 54 | class ScpTimeoutError(ScpError): 55 | """A device failed to respond to a SCP command within the timeout.""" 56 | 57 | 58 | class ScpMinorError(ScpError): 59 | """A device reported a SCP minor error.""" 60 | 61 | 62 | class ScpMajorError(ScpError): 63 | """A device reported a SCP major error.""" 64 | 65 | 66 | class ScpProtocolError(ScpError): 67 | """An unexpected SCP error occurred.""" 68 | 69 | 70 | class ScpChannelError(ScpError): 71 | """An error occurred with the SCP channel.""" 72 | 73 | 74 | class ScpClosedError(ScpError): 75 | """A device closed the SCP connection.""" 76 | 77 | 78 | class SshConfigError(ScpError): 79 | """The configuration file is either missing or malformed.""" 80 | 81 | 82 | class SshOptions(object): 83 | """Singleton wrapper class around the SSH configuration. 84 | 85 | This class creates a SSHOption object if the command line flag 86 | --paramiko_ssh_config was found and store the result for future 87 | use. Since this class is called from several threads, it uses a lock 88 | to protect concurrent attempts to load the configuration. 89 | """ 90 | _lock = threading.Lock() 91 | _need_init = True 92 | _ssh_options = None 93 | 94 | def __init__(self): 95 | """Read the configuration if present and store it for later. 96 | 97 | Check if the flag --paramiko_ssh_config was set and parse the 98 | configuration file. 99 | """ 100 | 101 | # This flag may be set by another thread concurrently. We will 102 | # check the value again under a lock. 103 | if SshOptions._need_init: 104 | try: 105 | with SshOptions._lock: 106 | if SshOptions._need_init and FLAGS.paramiko_ssh_config: 107 | logging.debug( 108 | 'Reading configuration from %s', FLAGS.paramiko_ssh_config) 109 | 110 | try: 111 | configfile = open(FLAGS.paramiko_ssh_config) 112 | ssh_config = paramiko.SSHConfig() 113 | ssh_config.parse(configfile) 114 | SshOptions._ssh_options = ssh_config 115 | except Exception as e: # pylint: disable=broad-except 116 | # Unfortunately paramiko raises "Exception" if there is an 117 | # error in the config file. 118 | logging.fatal('Unable to read or parse "%s": %s', 119 | FLAGS.paramiko_ssh_config, e) 120 | finally: 121 | SshOptions._need_init = False 122 | 123 | def Lookup(self, hostname, port, username): 124 | """Translate the hostname, port and username using the configuration. 125 | 126 | If the port is not defined, 22 is used. If the username is not 127 | defined and no option override it, it will remain undefined. 128 | 129 | Args: 130 | hostname: A string, the hostname to use as the key for searching the 131 | configuration. 132 | port: An integer, the TCP port to used to reach the device. If not 133 | defined, the default value (22) will be returned. 134 | username: A string, the username to use to connect to the device. It 135 | will only be overridden if not defined. 136 | Returns: 137 | A tuple of (string, int, string) containing the new (hostname, port, 138 | username). 139 | """ 140 | 141 | new_hostname = hostname 142 | new_port = port 143 | new_username = username 144 | 145 | if SshOptions._ssh_options: 146 | # We can't arrive here without first executing __init__, so we 147 | # can assume that the _ssh_option is set and we don't need a 148 | # lock since we're only doing readonly accesses. 149 | host_config = SshOptions._ssh_options.lookup(hostname) 150 | if host_config: 151 | if 'hostname' in host_config: 152 | new_hostname = host_config['hostname'] 153 | 154 | if (not new_port or new_port == 22) and 'port' in host_config: 155 | try: 156 | new_port = int(host_config['port']) 157 | except ValueError: 158 | raise SshConfigError('Invalid port value %s for %s' % 159 | (host_config['port'], hostname)) 160 | 161 | if not new_username and 'user' in host_config: 162 | new_username = host_config['user'] 163 | 164 | logging.debug( 165 | 'Translating %s:%s to %s:%s', hostname, port, new_hostname, 166 | new_port) 167 | 168 | if not new_port: 169 | new_port = 22 170 | 171 | return (new_hostname, new_port, new_username) 172 | 173 | 174 | def Connect(hostname, username, password=None, port=22, ssh_keys=(), 175 | timeout=TIMEOUT_DEFAULT): 176 | """Makes a paramiko SSH connection to a device. 177 | 178 | Args: 179 | hostname: A string, the hostname or IP address to connect to. 180 | username: A string, the username to use on the connection. 181 | password: A string, the password to use on the connection. 182 | port: An int, the port number to connect to. 183 | ssh_keys: A tuple of strings, SSH private keys (optional; may be None). 184 | timeout: A float, the number of seconds before a connection times out. 185 | 186 | Returns: 187 | A paramiko.SSHClient() instance 188 | """ 189 | 190 | options = SshOptions() 191 | hostname, port, username = options.Lookup(hostname, port, username) 192 | ssh_client = None 193 | 194 | def RaiseError(e, msg): 195 | """Raises an exception, disconnecting the SSH client. 196 | 197 | Args: 198 | e: An Exception. 199 | msg: An object, exception arguments. 200 | """ 201 | raise e(msg) 202 | 203 | try: 204 | ssh_client = paramiko.SSHClient() 205 | # Always auto-add remote SSH host keys. 206 | ssh_client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) 207 | ssh_client.load_system_host_keys() 208 | # Connect using paramiko with a timeout parameter (requires paramiko 1.7) 209 | if ssh_keys: 210 | pkeys = [] 211 | for key in ssh_keys: 212 | logging.debug('Using SSH private key for device authentication.') 213 | # Use a virtual temporary file to store the key. 214 | ssh_key_fileobj = cStringIO.StringIO() 215 | ssh_key_fileobj.write(key) 216 | ssh_key_fileobj.reset() 217 | try: 218 | pkeys.append(paramiko.DSSKey(file_obj=ssh_key_fileobj)) 219 | logging.debug('Using SSH DSA key for %r', hostname) 220 | except (IndexError, paramiko.SSHException) as e: 221 | if (isinstance(e, IndexError) or 222 | 'not a valid DSA private key file' in str(e)): 223 | ssh_key_fileobj.reset() 224 | try: 225 | logging.debug('Using SSH RSA key for %r', hostname) 226 | pkeys.append(paramiko.RSAKey(file_obj=ssh_key_fileobj)) 227 | except (IndexError, paramiko.SSHException) as e: 228 | raise exceptions.AuthenticationError(str(e)) 229 | else: 230 | raise exceptions.ConnectError('SSHException: %s' % str(e)) 231 | else: 232 | logging.debug('Using password for %r', hostname) 233 | pkeys = [None] 234 | for pkey in pkeys: 235 | saved_exception = None 236 | try: 237 | ssh_client.connect(hostname=hostname, 238 | port=port, 239 | username=username, 240 | password=password, 241 | pkey=pkey, 242 | timeout=timeout, 243 | key_filename=FLAGS.key, 244 | allow_agent=FLAGS.use_ssh_agent, 245 | look_for_keys=False) 246 | break 247 | except (paramiko.AuthenticationException, paramiko.SSHException) as e: 248 | saved_exception = e 249 | if saved_exception is not None: 250 | raise saved_exception # pylint: disable=raising-bad-type 251 | transport = ssh_client.get_transport() 252 | # Sometimes we have to authenticate a second time, eg. on Force10 253 | # we always fail the first authentication (if we try pkey + pass, 254 | # the pass succeeds; but if we do pass only, we have to do it 255 | # twice). connect() above will have authenticated once. 256 | if not transport.is_authenticated(): 257 | if pkeys != [None]: 258 | for pkey in pkeys: 259 | try: 260 | transport.auth_publickey(username, pkey) 261 | break 262 | except paramiko.SSHException: 263 | pass 264 | if not transport.is_authenticated(): 265 | if password is not None: 266 | try: 267 | transport.auth_password(username, password) 268 | except paramiko.SSHException: 269 | pass 270 | if not transport.is_authenticated(): 271 | msg = 'Not authenticated after two attempts on %r' % hostname 272 | RaiseError(exceptions.ConnectError, msg) 273 | except EOFError: 274 | msg = 'EOFError connecting to: %r' % hostname 275 | RaiseError(exceptions.ConnectError, msg) 276 | except paramiko.AuthenticationException as e: 277 | msg = 'Authentication error connecting to %s: %s' % (hostname, str(e)) 278 | RaiseError(exceptions.AuthenticationError, msg) 279 | except paramiko.SSHException as e: 280 | msg = 'SSHException connecting to %s: %s' % (hostname, str(e)) 281 | RaiseError(exceptions.ConnectError, msg) 282 | except socket.timeout as e: 283 | msg = 'Timed-out while connecting to %s: %s' % (hostname, str(e)) 284 | RaiseError(exceptions.ConnectError, msg) 285 | except socket.error as e: 286 | msg = 'Socket error connecting to %r: %s %s' % (hostname, e.__class__, e) 287 | RaiseError(exceptions.ConnectError, msg) 288 | 289 | return ssh_client 290 | 291 | 292 | def _ScpRecvResponse(channel): 293 | """Receives a response on a SCP channel. 294 | 295 | Args: 296 | channel: A Paramiko channel object. 297 | 298 | Raises: 299 | ScpClosedError: If the device has closed the connection. 300 | ScpMajorError: If the device reports a major error. 301 | ScpMinorError: If the device reports a minor error. 302 | ScpProtocolError: If an unexpected error occurs. 303 | ScpTimeoutError: If no response is received within the timeout. 304 | """ 305 | buf = channel.recv(1) 306 | while True: 307 | if channel.recv_stderr_ready(): 308 | # Dodgy: Cisco sometimes *ask* for a password, but they don't actually 309 | err = channel.recv_stderr(512) 310 | if err == 'Password: ': 311 | logging.warn('Password prompt received on SCP stderr, assuming ' 312 | 'IOS bug (ignoring)') 313 | else: 314 | raise ScpProtocolError('Data on stderr: %r' % err) 315 | 316 | if not buf: 317 | raise ScpClosedError('Connection closed by remote device') 318 | 319 | if buf == '\x00': 320 | # Code \x00 indicates success. Brocade have been observed sending 321 | # \x00\x02 followed by an error message, so we need to only read 322 | # the single \x00 and leave the error message to be handled in a 323 | # future call to _ScpRecvResponse. 324 | return 325 | 326 | try: 327 | extra = channel.recv(512) 328 | if not extra: 329 | raise ScpProtocolError( 330 | 'Connection closed by remote device; partial response: %r' % buf) 331 | else: 332 | buf += extra 333 | except socket.timeout: 334 | if buf: 335 | raise ScpProtocolError( 336 | 'Timed out reading from socket; partial response: %r' % buf) 337 | else: 338 | raise ScpTimeoutError('Timed out reading from socket') 339 | 340 | if buf[-1] == '\n': 341 | if buf[0] == '\x01': 342 | if buf.startswith('\x01File ') and buf.rstrip().endswith( 343 | 'created successfully.'): 344 | return 345 | raise ScpMinorError(buf[1:-1]) 346 | elif buf[0] == '\x02': 347 | # Code \x02: Fatal error. 348 | raise ScpMajorError(buf[1:-1]) 349 | else: 350 | # Default case: Fatal error. 351 | raise ScpMajorError(buf[:-1]) 352 | 353 | 354 | def ScpPut(transport, source_data, destination_file, timeout, send_buffer=8192): 355 | """Puts a file via SCP protocol. 356 | 357 | Args: 358 | transport: A Paramiko transport object. 359 | source_data: The source data to copy as a string. 360 | destination_file: The file on the remote device. 361 | timeout: The timeout to use for the SCP channel. 362 | send_buffer: The number of bytes to send in each operation. 363 | 364 | Raises: 365 | ConnectionError: There was an error trying to start the SCP connection. 366 | ScpError: There was an error copying the file. 367 | """ 368 | channel = transport.open_session() 369 | try: 370 | channel.settimeout(timeout) 371 | channel.exec_command('scp -t %s' % destination_file) 372 | 373 | # Server must acknowledge our connection. 374 | _ScpRecvResponse(channel) 375 | 376 | # Send file attributes, length and a dummy source file basename. 377 | source_size = len(source_data) 378 | channel.sendall('C0644 %d 1\n' % source_size) 379 | 380 | # Server must acknowledge our request to send. 381 | _ScpRecvResponse(channel) 382 | 383 | # Send the data in chunks rather than all at once 384 | pos = 0 385 | while pos < source_size: 386 | channel.sendall(source_data[pos:pos + send_buffer]) 387 | pos += send_buffer 388 | 389 | # Indicate that we experienced no errors while sending. 390 | channel.sendall('\0') 391 | 392 | # Get the final status back from the device. Note: Force10 actually sends 393 | # final status prior to getting the "all OK" from us. 394 | _ScpRecvResponse(channel) 395 | finally: 396 | try: 397 | channel.close() 398 | except EOFError: 399 | raise ScpChannelError('Error closing SCP channel') 400 | --------------------------------------------------------------------------------