-
Notifications
You must be signed in to change notification settings - Fork 0
/
junos.py
executable file
·503 lines (441 loc) · 19.3 KB
/
junos.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
#!/usr/bin/python
#
# Copyright 2014 Google Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""A JunOS device.
This module implements the device interface of base_device.py for
Juniper Networks' devices running the JunOS operating system.
These devices are typically routers, such as the T640 and MX960.
"""
import hashlib
import os
import re
import tempfile
import threading
import paramiko
import gflags
import logging
import base_device
import paramiko_device
import push_exceptions as exceptions
FLAGS = gflags.FLAGS
gflags.DEFINE_float('junos_timeout_response', None,
'JunOS device response timeout in seconds.')
gflags.DEFINE_float('junos_timeout_connect', None,
'JunOS device connect timeout in seconds.')
gflags.DEFINE_float('junos_timeout_idle', None,
'JunOS device idle timeout in seconds.')
gflags.DEFINE_float('junos_timeout_disconnect', None,
'JunOS device disconnect timeout in seconds.')
gflags.DEFINE_float('junos_timeout_act_user', None,
'JunOS device user activation timeout in seconds.')
gflags.DEFINE_boolean('paramiko_logging',
False,
'Log Paramiko output to STDERR found.')
class JunosDevice(paramiko_device.ParamikoDevice):
"""A device model suitable for Juniper JunOS devices.
See the base_device.BaseDevice method docstrings.
"""
# Used to protect the SetupParamikoLogging method, and state.
_paramiko_logging_lock = threading.Lock()
_paramiko_logging_initialized = False
# Response strings that indicate an error during SetConfig().
JUNOS_LOAD_ERRORS = ('error:',
' errors',
'error recovery ignores input until this point:')
# Response strings that do *not* indicate an error, and should be ignored
# This currently matches that start with a "diff" character, or indicate a
# failure to communicate with an RE.
IGNORED_JUNOS_LINES = re.compile(
r'^[+!-]|' # Match diff characters at start of line.
r'(?<!syntax )error: .*connect to re[0-9] :', # Match missing RE errors.
re.IGNORECASE)
@staticmethod
def _CleanupErrorLine(line):
"""Removes text from "line" which does not indicate an error.
This is a helper function for _RaiseExceptionIfLoadError.
If the line contains a single or double quote character, then it's assumed
to start a quoted chunk of configuration from the client. Characters after
the quote are ignored.
If the (remaining part of the) line matches IGNORED_JUNOS_LINES, '' is
returned.
Otherwise, returns the characters before the first quote.
This avoids problems where a user-entered interface description contains the
word "error", for example.
Args:
line: A string, the string to clean up.
Returns:
The line, or a substring of the line (can be empty).
"""
# Chop off anything after single or double quotes first.
remaining = line.partition('\'')[0].partition('"')[0]
if JunosDevice.IGNORED_JUNOS_LINES.match(remaining):
return ''
else:
return remaining
@staticmethod
def _RaiseExceptionIfLoadError(result, expect_config_check=False,
expect_commit=False):
"""Checks if a result string from a load configuration contains an error.
Args:
result: A string, the result of loading the configuration.
expect_config_check: A boolean. If true, then the exception-raising code
will raise a special "configuration check failed" exception if the
string "configuration check succeeds" isn't found.
expect_commit: A boolean. If True, then the function raises an exception
if the string "commit complete" isn't found on a line by itself.
Raises:
An exception derived from exceptions.SetConfigError if the result
indicates an error, else nothing.
"""
# Remove output assumed to be part of diffs or quoted parts of the input.
# Lines that are considered to be part of diffs start with + or - or !,
# start and end with square brackets like "[edit ... ]", or immediately
# follow a line that starts and ends with square brackets.
lines = []
last_line_started_diff = False
for line in result.splitlines():
if last_line_started_diff:
last_line_started_diff = False
# Ignore the line.
elif line.startswith('[') and line.endswith(']'):
last_line_started_diff = True
else:
lines.append(JunosDevice._CleanupErrorLine(line))
for error in JunosDevice.JUNOS_LOAD_ERRORS:
if any(error in line for line in lines):
break
else:
# No special "error" string found, check for "commit complete".
if expect_commit and all(
'commit complete' not in line for line in lines):
raise exceptions.SetConfigError(
'"commit complete" expected, but not found in output:\n%s' % result)
return
# Raise the right type of exception based on the error string found.
if any('syntax error' in line for line in lines):
raise exceptions.SetConfigSyntaxError(
'Device reports a syntax error in the configuration.\n%s' %
result)
elif expect_config_check and all(
'configuration check succeeds' not in line for line in lines):
raise exceptions.SetConfigSyntaxError(
'Configuration check failed.\n%s' % result)
else:
raise exceptions.SetConfigError(
'Error occurred during config load.\n%s' % result)
def __init__(self, **kwargs):
self.vendor_name = 'junos'
self.unsupported_non_file_destinations = ()
super(JunosDevice, self).__init__(**kwargs)
# Setup paramiko logging once only.
if not JunosDevice._paramiko_logging_initialized:
self._SetupParamikoLogging()
def _SetupTimeouts(self):
if FLAGS.junos_timeout_idle is None:
self.timeout_idle = 1790.0
def _SetupParamikoLogging(self):
if not JunosDevice._paramiko_logging_initialized and FLAGS.paramiko_logging:
# UN*X specific.
log_dir_string = '/dev/fd/2'
logging.info('Paramiko SSH2 logging to path: %r', log_dir_string)
paramiko.util.log_to_file(log_dir_string)
JunosDevice._paramiko_logging_initialized = True
def _Cmd(self, command, mode=None):
# Enforce that the 'ping' and 'monitor' commands have a count (else they
# never complete). JunOS allows these to be abbreviated to 'p' and 'mo'
# since no other commands begin with those prefixes.
if command.startswith('p') or command.startswith('mo'):
if ' count ' not in command:
# Use 5 pings by default (same as default for 'ping <host> rapid').
command += ' count 5'
# Enforce that traceroute and monitor have the stderr (header) and stdout
# merged, in that order.
if command.startswith('tr') or command.startswith('mo'):
merge_stderr_first = True
else:
merge_stderr_first = False
# Run the modified command.
result = super(JunosDevice, self)._Cmd(
command, mode=mode,
merge_stderr_first=merge_stderr_first,
require_low_chanid=True)
# Report JunOS errors on stdout as CmdError.
if result.startswith('\nerror: '):
raise exceptions.CmdError(result[1:]) # Drop the leading \n.
else:
return result
def _ChecksumsMatch(self, local_file_name, remote_file_name):
"""Compares the local and remote checksums for the named file.
Args:
local_file_name: A string, the filename on the local host.
remote_file_name: A string, the file path and name of the remote file.
Returns:
A boolean. True iff the checksums match, else False.
"""
remote_md5 = self._Cmd('file checksum md5 ' + remote_file_name)
logging.debug('Remote checksum output: %s', remote_md5)
local_md5 = hashlib.md5(open(local_file_name).read()).hexdigest()
logging.debug('Local checksum: %s', local_md5)
try:
if local_md5 == remote_md5.split()[3]:
logging.debug('PASS MD5 checksums match.')
return True
else:
logging.error('FAIL MD5 checksums do not match.')
return False
except IndexError:
logging.error('ERROR MD5 checksum parse error.')
logging.error('ERROR local checksum: %r', local_md5)
logging.error('ERROR remote checksum: %r', remote_md5)
return False
def _GetConfig(self, source_file):
"""Gets file or running configuration from the remote device.
Args:
source_file: A string, containing path to the file that should be
retrieved from the remote device. It can also contain the defined
reserved word self.CONFIG_RUNNING, in which case this method
retrieves the running configuration from the remote device.
Returns:
response: A string, content of the retrieved file or running
configuration.
Raises:
exceptions.GetConfigError: An error occured during the retrieval.
exceptions.EmptyConfigError: Running configuration is empty.
"""
response = ''
if source_file == self.CONFIG_RUNNING:
try:
response = self._Cmd('show configuration')
except exceptions.CmdError:
msg = ('Could not retrieve system configuration from %s' %
repr(self.host))
logging.error(msg)
raise exceptions.GetConfigError(msg)
if not response:
raise exceptions.EmptyConfigError(
'Configuration of %s is empty' % repr(self.host))
else:
tempfile_ptr = tempfile.NamedTemporaryFile()
try:
self._GetFileViaSftp(local_filename=tempfile_ptr.name,
remote_filename=source_file)
except (paramiko.SFTPError, IOError) as e:
msg = ('Could not retrieve configuration file %r from %s, '
'error: %s' % (source_file, self.host, e))
logging.error(msg)
raise exceptions.GetConfigError(msg)
response = tempfile_ptr.read()
return response
def _JunosLoad(self, operation, filename, canary=False,
skip_show_compare=False, skip_commit_check=False,
rollback_patch=None):
"""Loads the configuration to the remote device using a given operation.
Args:
operation: A string, the load operation (e.g., 'replace', 'override').
filename: A string, the remote temporary filename to stage configuration.
canary: A boolean, if True, only canary check the configuration, don't
apply it.
skip_show_compare: A boolean, if True, "show | compare" will be skipped.
This is a temporary flag due to a JunOS bug and may be removed in the
future.
skip_commit_check: A boolean, if True, "commit check" (running the commit
scripts) will be skipped in canary mode.
rollback_patch: None or a string, optional filename into which to
record and return a patch to rollback the config change.
Returns:
A base_device.SetConfigResult, all responses from the router during the
check/load operation, plus any optional extras.
"""
show_compare = 'show | compare; '
if skip_show_compare:
show_compare = ''
if canary:
commit_check = 'commit check; '
if skip_commit_check:
commit_check = ''
cmd = ('edit exclusive; load %s %s; %s%srollback 0; exit' %
(operation, filename, show_compare, commit_check))
else:
save_rollback_patch = ''
if rollback_patch:
save_rollback_patch = ('rollback 1; show | compare | save %s; rollback;'
% rollback_patch)
cmd = ('edit exclusive; load %s %s; %s'
'commit comment "push: load %s %s";%s exit' %
(operation, filename, show_compare, operation, filename,
save_rollback_patch))
result = base_device.SetConfigResult()
result.transcript = self._Cmd(cmd)
self._RaiseExceptionIfLoadError(
result.transcript,
expect_config_check=canary and not skip_commit_check,
expect_commit=not canary)
return result
def _SetConfig(self, destination_file, data, canary, skip_show_compare=False,
skip_commit_check=False, get_rollback_patch=False):
copied = False
file_ptr = tempfile.NamedTemporaryFile()
rollback_patch_ptr = tempfile.NamedTemporaryFile()
rollback_patch = None
# Setting the file name based upon if we are trying to copy a file or
# we are trying to copy a config into the control plane.
if destination_file in self.NON_FILE_DESTINATIONS:
file_name = os.path.basename(file_ptr.name)
if get_rollback_patch:
rollback_patch = os.path.basename(rollback_patch_ptr.name)
else:
file_name = destination_file
logging.info('Remote file path: %s', file_name)
try:
file_ptr.write(data)
file_ptr.flush()
except IOError:
raise exceptions.SetConfigError('Could not open temporary file %r' %
file_ptr.name)
result = base_device.SetConfigResult()
try:
# Copy the file to the remote device.
try:
self._SendFileViaSftp(local_filename=file_ptr.name,
remote_filename=file_name)
copied = True
except (paramiko.SFTPError, IOError) as e:
# _SendFileViaSftp puts the normalized destination path in e.args[1].
msg = 'SFTP failed (filename %r to device %s(%s):%s): %s: %s' % (
file_ptr.name, self.host, self.loopback_ipv4, e.args[1],
e.__class__.__name__, e.args[0])
raise exceptions.SetConfigError(msg)
if not self._ChecksumsMatch(local_file_name=file_ptr.name,
remote_file_name=file_name):
raise exceptions.SetConfigError(
'Local and remote file checksum mismatch.')
if self.CONFIG_RUNNING == destination_file:
operation = 'replace'
elif self.CONFIG_STARTUP == destination_file:
operation = 'override'
elif self.CONFIG_PATCH == destination_file:
operation = 'patch'
else:
result.transcript = 'SetConfig uploaded the file successfully.'
return result
if canary:
logging.debug('Canary syntax checking configuration file %r.',
file_name)
result = self._JunosLoad(operation, file_name, canary=True,
skip_show_compare=skip_show_compare,
skip_commit_check=skip_commit_check)
else:
logging.debug('Setting destination %r with configuration file %r.',
destination_file, file_name)
result = self._JunosLoad(operation, file_name,
skip_show_compare=skip_show_compare,
skip_commit_check=skip_commit_check,
rollback_patch=rollback_patch)
if rollback_patch:
try:
self._GetFileViaSftp(local_filename=rollback_patch_ptr.name,
remote_filename=rollback_patch)
result.rollback_patch = rollback_patch_ptr.read()
except (paramiko.SFTPError, IOError) as e:
# _GetFileViaSftp puts the normalized source path in e.args[1].
result.transcript += (
'SFTP rollback patch retrieval failed '
'(filename %r from device %s(%s):%s): %s: %s' % (
rollback_patch_ptr.name, self.host, self.loopback_ipv4,
e.args[1], e.__class__.__name__, e.args[0]))
# Return the diagnostic results as the (optional) result.
return result
finally:
local_delete_exception = None
# Unlink the original temporary file.
try:
logging.info('Deleting the file on the local machine: %s',
file_ptr.name)
file_ptr.close()
except IOError:
local_delete_exception = exceptions.SetConfigError(
'Could not close temporary file.')
local_rollback_patch_delete_exception = None
# Unlink the rollback patch temporary file.
try:
logging.info('Deleting the file on the local machine: %s',
rollback_patch_ptr.name)
rollback_patch_ptr.close()
except IOError:
local_rollback_patch_delete_exception = exceptions.SetConfigError(
'Could not close temporary rollback patch file.')
# If we copied the file to the router and we were pushing a configuration,
# delete the temporary file off the router.
if copied and destination_file in self.NON_FILE_DESTINATIONS:
logging.info('Deleting file on the router: %s', file_name)
self.Cmd('file delete ' + file_name)
# Delete any rollback patch file too.
if rollback_patch:
logging.info('Deleting patch on the router: %s', rollback_patch)
self.Cmd('file delete ' + rollback_patch)
# If we got an exception on the local file delete, but did not get a
# (more important) exception on the remote delete, raise the local delete
# exception.
#
# pylint is confused by the re-raising
# pylint: disable=raising-bad-type
if local_delete_exception is not None:
raise local_delete_exception
if local_rollback_patch_delete_exception is not None:
raise local_rollback_patch_delete_exception
def _GetFileViaSftp(self, local_filename, remote_filename):
"""Gets the file named remote_filename from the remote device via SFTP.
Args:
local_filename: A string, the filename (must exist).
remote_filename: A string, the path to the remote file location and
filename.
Raises:
paramiko.SFTPError: An error occurred during the SFTP.
IOError: There was an IOError accessing the named file.
"""
sftp = self._ssh_client.open_sftp()
try:
sftp.get(remote_filename, local_filename)
except (paramiko.SFTPError, IOError) as e:
try:
remote_filename = sftp.normalize(remote_filename)
except (paramiko.SFTPError, IOError):
pass
raise e.__class__(e.args[0], remote_filename)
finally:
sftp.close() # Request close from peer.
def _SendFileViaSftp(self, local_filename, remote_filename):
"""Sends the file named filename to the remote device via SFTP.
Args:
local_filename: A string, the filename (must exist).
remote_filename: A string, the path to the remote file location and
filename.
Returns:
A tuple like stat() returns, the remote file's stat result.
Raises:
paramiko.SFTPError: An error occurred during the SFTP.
IOError: There was an IOError accessing the named file.
"""
sftp = self._ssh_client.open_sftp()
try:
sftp.put(local_filename, remote_filename)
except (paramiko.SFTPError, IOError) as e:
try:
remote_filename = sftp.normalize(remote_filename)
except (paramiko.SFTPError, IOError):
pass
raise e.__class__(e.args[0], remote_filename)
finally:
sftp.close() # Request close from peer.