trigger 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- trigger/__init__.py +7 -0
- trigger/acl/__init__.py +32 -0
- trigger/acl/autoacl.py +70 -0
- trigger/acl/db.py +324 -0
- trigger/acl/dicts.py +357 -0
- trigger/acl/grammar.py +112 -0
- trigger/acl/ios.py +222 -0
- trigger/acl/junos.py +422 -0
- trigger/acl/models.py +118 -0
- trigger/acl/parser.py +168 -0
- trigger/acl/queue.py +296 -0
- trigger/acl/support.py +1431 -0
- trigger/acl/tools.py +746 -0
- trigger/bin/__init__.py +0 -0
- trigger/bin/acl.py +233 -0
- trigger/bin/acl_script.py +574 -0
- trigger/bin/aclconv.py +82 -0
- trigger/bin/check_access.py +93 -0
- trigger/bin/check_syntax.py +66 -0
- trigger/bin/fe.py +197 -0
- trigger/bin/find_access.py +191 -0
- trigger/bin/gnng.py +434 -0
- trigger/bin/gong.py +86 -0
- trigger/bin/load_acl.py +841 -0
- trigger/bin/load_config.py +18 -0
- trigger/bin/netdev.py +317 -0
- trigger/bin/optimizer.py +638 -0
- trigger/bin/run_cmds.py +18 -0
- trigger/changemgmt/__init__.py +352 -0
- trigger/changemgmt/bounce.py +57 -0
- trigger/cmds.py +1217 -0
- trigger/conf/__init__.py +94 -0
- trigger/conf/global_settings.py +674 -0
- trigger/contrib/__init__.py +7 -0
- trigger/exceptions.py +307 -0
- trigger/gorc.py +172 -0
- trigger/netdevices/__init__.py +1288 -0
- trigger/netdevices/loader.py +174 -0
- trigger/netscreen.py +1030 -0
- trigger/packages/__init__.py +6 -0
- trigger/packages/peewee.py +8084 -0
- trigger/rancid.py +463 -0
- trigger/tacacsrc.py +584 -0
- trigger/twister.py +2203 -0
- trigger/twister2.py +745 -0
- trigger/utils/__init__.py +88 -0
- trigger/utils/cli.py +349 -0
- trigger/utils/importlib.py +77 -0
- trigger/utils/network.py +157 -0
- trigger/utils/rcs.py +178 -0
- trigger/utils/templates.py +81 -0
- trigger/utils/url.py +78 -0
- trigger/utils/xmltodict.py +298 -0
- trigger-2.0.0.dist-info/METADATA +146 -0
- trigger-2.0.0.dist-info/RECORD +61 -0
- trigger-2.0.0.dist-info/WHEEL +5 -0
- trigger-2.0.0.dist-info/entry_points.txt +15 -0
- trigger-2.0.0.dist-info/licenses/AUTHORS.md +20 -0
- trigger-2.0.0.dist-info/licenses/LICENSE.md +28 -0
- trigger-2.0.0.dist-info/top_level.txt +2 -0
- twisted/plugins/trigger_xmlrpc.py +124 -0
trigger/twister.py
ADDED
|
@@ -0,0 +1,2203 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Login and basic command-line interaction support using the Twisted asynchronous
|
|
3
|
+
I/O framework. The Trigger Twister is just like the Mersenne Twister, except
|
|
4
|
+
not at all.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import copy
|
|
8
|
+
import fcntl
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import signal
|
|
12
|
+
import socket
|
|
13
|
+
import struct
|
|
14
|
+
import sys
|
|
15
|
+
import tty
|
|
16
|
+
from xml.etree.ElementTree import Element, ElementTree, TreeBuilder
|
|
17
|
+
|
|
18
|
+
from twisted.conch import telnet
|
|
19
|
+
from twisted.conch.client.default import SSHUserAuthClient
|
|
20
|
+
from twisted.conch.ssh import channel, common, session, transport
|
|
21
|
+
from twisted.conch.ssh.connection import SSHConnection
|
|
22
|
+
from twisted.internet import defer, protocol, reactor, stdio
|
|
23
|
+
from twisted.protocols.policies import TimeoutMixin
|
|
24
|
+
from twisted.python import log
|
|
25
|
+
from twisted.python.usage import Options
|
|
26
|
+
|
|
27
|
+
from trigger import exceptions, tacacsrc
|
|
28
|
+
from trigger.conf import settings
|
|
29
|
+
from trigger.utils import cli, network
|
|
30
|
+
|
|
31
|
+
__author__ = "Jathan McCollum, Eileen Tschetter, Mark Thomas, Michael Shields"
|
|
32
|
+
__maintainer__ = "Jathan McCollum"
|
|
33
|
+
__email__ = "jathan@gmail.com"
|
|
34
|
+
__copyright__ = "Copyright 2006-2013, AOL Inc.; 2013 Salesforce.com"
|
|
35
|
+
__version__ = "1.5.8"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# Exports
|
|
39
|
+
# TODO (jathan): Setting this prevents everything from showing up in the Sphinx
|
|
40
|
+
# docs; so let's make sure we account for that ;)
|
|
41
|
+
# __all__ = ('connect', 'execute', 'stop_reactor')
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# Functions
|
|
45
|
+
# ==================
|
|
46
|
+
# Helper functions
|
|
47
|
+
# ==================
|
|
48
|
+
def has_junoscript_error(tag):
|
|
49
|
+
"""Test whether an Element contains a Junoscript xnm:error."""
|
|
50
|
+
if ElementTree(tag).find(".//{http://xml.juniper.net/xnm/1.1/xnm}error"):
|
|
51
|
+
return True
|
|
52
|
+
return False
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def has_juniper_error(s):
|
|
56
|
+
"""Test whether a string seems to contain an Juniper error."""
|
|
57
|
+
tests = (
|
|
58
|
+
"unknown command." in s,
|
|
59
|
+
"syntax error, " in s,
|
|
60
|
+
"invalid value." in s,
|
|
61
|
+
"missing argument." in s,
|
|
62
|
+
)
|
|
63
|
+
return any(tests)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def has_ioslike_error(s):
|
|
67
|
+
"""Test whether a string seems to contain an IOS-like error."""
|
|
68
|
+
tests = (
|
|
69
|
+
s.startswith("%"), # Cisco, Arista
|
|
70
|
+
"\n%" in s, # A10, Aruba, Foundry
|
|
71
|
+
"syntax error: " in s.lower(), # Brocade VDX, F5 BIGIP
|
|
72
|
+
s.startswith("Invalid input -> "), # Brocade MLX
|
|
73
|
+
s.endswith("Syntax Error"), # MRV
|
|
74
|
+
)
|
|
75
|
+
return any(tests)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def has_netscaler_error(s):
|
|
79
|
+
"""Test whether a string seems to contain a NetScaler error."""
|
|
80
|
+
tests = (
|
|
81
|
+
s.startswith("ERROR: "),
|
|
82
|
+
"\nERROR: " in s,
|
|
83
|
+
s.startswith("Warning: "),
|
|
84
|
+
"\nWarning: " in s,
|
|
85
|
+
)
|
|
86
|
+
return any(tests)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def is_awaiting_confirmation(prompt):
|
|
90
|
+
"""
|
|
91
|
+
Checks if a prompt is asking for us for confirmation and returns a Boolean.
|
|
92
|
+
|
|
93
|
+
New patterns may be added by customizing ``settings.CONTINUE_PROMPTS``.
|
|
94
|
+
|
|
95
|
+
>>> from trigger.twister import is_awaiting_confirmation
|
|
96
|
+
>>> is_awaiting_confirmation('Destination filename [running-config]? ')
|
|
97
|
+
True
|
|
98
|
+
|
|
99
|
+
:param prompt:
|
|
100
|
+
The prompt string to check
|
|
101
|
+
"""
|
|
102
|
+
prompt = prompt.lower()
|
|
103
|
+
matchlist = settings.CONTINUE_PROMPTS
|
|
104
|
+
return any(prompt.endswith(match.lower()) for match in matchlist)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def requires_enable(proto_obj, data):
|
|
108
|
+
"""
|
|
109
|
+
Check if a device requires enable.
|
|
110
|
+
|
|
111
|
+
:param proto_obj:
|
|
112
|
+
A Protocol object such as an SSHChannel
|
|
113
|
+
|
|
114
|
+
:param data:
|
|
115
|
+
The channel data to check for an enable prompt
|
|
116
|
+
"""
|
|
117
|
+
if not proto_obj.device.is_ioslike():
|
|
118
|
+
log.msg(f"[{proto_obj.device}] Not IOS-like, setting enabled flag")
|
|
119
|
+
proto_obj.enabled = True
|
|
120
|
+
return None
|
|
121
|
+
match = proto_obj.enable_prompt.search(data)
|
|
122
|
+
if match is not None:
|
|
123
|
+
log.msg(f"[{proto_obj.device}] Enable prompt detected: {match.group()!r}")
|
|
124
|
+
return match
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def send_enable(proto_obj, disconnect_on_fail=True):
|
|
128
|
+
"""
|
|
129
|
+
Send 'enable' and enable password to device.
|
|
130
|
+
|
|
131
|
+
:param proto_obj:
|
|
132
|
+
A Protocol object such as an SSHChannel
|
|
133
|
+
|
|
134
|
+
:param disconnect_on_fail:
|
|
135
|
+
If set, will forcefully disconnect on enable password failure
|
|
136
|
+
"""
|
|
137
|
+
log.msg(f"[{proto_obj.device}] Enable required, sending enable commands")
|
|
138
|
+
|
|
139
|
+
# Get enable password from env. or device object
|
|
140
|
+
device_pw = getattr(proto_obj.device, "enablePW", None)
|
|
141
|
+
enable_pw = os.getenv("TRIGGER_ENABLEPW") or device_pw
|
|
142
|
+
if enable_pw is not None:
|
|
143
|
+
log.msg(f"[{proto_obj.device}] Enable password detected, sending...")
|
|
144
|
+
proto_obj.data = "" # Zero out the buffer before sending the password
|
|
145
|
+
proto_obj.write("enable" + proto_obj.device.delimiter)
|
|
146
|
+
|
|
147
|
+
# In low latency environments (< 1ms), we might send the password
|
|
148
|
+
# before the "Password:" prommpt is displayed. Here we wait a split
|
|
149
|
+
# second for the password prompt to appear before sending the
|
|
150
|
+
# password. See: https://github.com/trigger/trigger/issues/238
|
|
151
|
+
from twisted.internet import reactor
|
|
152
|
+
|
|
153
|
+
reactor.callLater(0.1, proto_obj.write, enable_pw + proto_obj.device.delimiter)
|
|
154
|
+
proto_obj.enabled = True
|
|
155
|
+
else:
|
|
156
|
+
log.msg(f"[{proto_obj.device}] Enable password not found, not enabling.")
|
|
157
|
+
proto_obj.factory.err = exceptions.EnablePasswordFailure(
|
|
158
|
+
"Enable password not set. See documentation on "
|
|
159
|
+
"settings.TRIGGER_ENABLEPW for help."
|
|
160
|
+
)
|
|
161
|
+
if disconnect_on_fail:
|
|
162
|
+
proto_obj.loseConnection()
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def stop_reactor():
|
|
166
|
+
"""Stop the reactor if it's already running."""
|
|
167
|
+
from twisted.internet import reactor
|
|
168
|
+
|
|
169
|
+
if reactor.running:
|
|
170
|
+
log.msg("Stopping reactor")
|
|
171
|
+
reactor.stop()
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# ==================
|
|
175
|
+
# PTY functions
|
|
176
|
+
# ==================
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def pty_connect(
|
|
180
|
+
device, action, creds=None, display_banner=None, ping_test=False, init_commands=None
|
|
181
|
+
):
|
|
182
|
+
"""
|
|
183
|
+
Connect to a ``device`` and log in. Use SSHv2 or telnet as appropriate.
|
|
184
|
+
|
|
185
|
+
:param device:
|
|
186
|
+
A `~trigger.netdevices.NetDevice` object.
|
|
187
|
+
|
|
188
|
+
:param action:
|
|
189
|
+
A Twisted ``Protocol`` instance (not class) that will be activated when
|
|
190
|
+
the session is ready.
|
|
191
|
+
|
|
192
|
+
:param creds:
|
|
193
|
+
A 2-tuple (username, password). By default, credentials from
|
|
194
|
+
``.tacacsrc`` will be used according to ``settings.DEFAULT_REALM``.
|
|
195
|
+
Override that here.
|
|
196
|
+
|
|
197
|
+
:param display_banner:
|
|
198
|
+
Will be called for SSH pre-authentication banners. It will receive two
|
|
199
|
+
args, ``banner`` and ``language``. By default, nothing will be done
|
|
200
|
+
with the banner.
|
|
201
|
+
|
|
202
|
+
:param ping_test:
|
|
203
|
+
If set, the device is pinged and must succeed in order to proceed.
|
|
204
|
+
|
|
205
|
+
:param init_commands:
|
|
206
|
+
A list of commands to execute upon logging into the device.
|
|
207
|
+
|
|
208
|
+
:returns: A Twisted ``Deferred`` object
|
|
209
|
+
"""
|
|
210
|
+
d = defer.Deferred()
|
|
211
|
+
|
|
212
|
+
# Only proceed if ping succeeds
|
|
213
|
+
if ping_test:
|
|
214
|
+
log.msg(f"Pinging {device}", debug=True)
|
|
215
|
+
if not network.ping(device.nodeName):
|
|
216
|
+
log.msg(f"Ping to {device} failed", debug=True)
|
|
217
|
+
return None
|
|
218
|
+
|
|
219
|
+
# SSH?
|
|
220
|
+
if device.can_ssh_pty():
|
|
221
|
+
interactive = hasattr(sys, "ps1")
|
|
222
|
+
all_tty = all(x.isatty() for x in (sys.stderr, sys.stdin, sys.stdout))
|
|
223
|
+
log.msg(f"[{device}] SSH connection test PASSED")
|
|
224
|
+
if interactive or not all_tty:
|
|
225
|
+
# Shell not in interactive mode.
|
|
226
|
+
pass
|
|
227
|
+
|
|
228
|
+
else:
|
|
229
|
+
if not creds and device.is_firewall():
|
|
230
|
+
creds = tacacsrc.get_device_password(device.nodeName)
|
|
231
|
+
|
|
232
|
+
factory = TriggerSSHPtyClientFactory(
|
|
233
|
+
d, action, creds, display_banner, init_commands, device=device
|
|
234
|
+
)
|
|
235
|
+
port = device.nodePort or settings.SSH_PORT
|
|
236
|
+
log.msg(f"Trying SSH to {device}:{port}", debug=True)
|
|
237
|
+
|
|
238
|
+
# or Telnet?
|
|
239
|
+
elif settings.TELNET_ENABLED:
|
|
240
|
+
log.msg(f"[{device}] SSH connection test FAILED, falling back to telnet")
|
|
241
|
+
factory = TriggerTelnetClientFactory(
|
|
242
|
+
d, action, creds, init_commands=init_commands, device=device
|
|
243
|
+
)
|
|
244
|
+
port = device.nodePort or settings.TELNET_PORT
|
|
245
|
+
log.msg(f"Trying telnet to {device}:{port}", debug=True)
|
|
246
|
+
else:
|
|
247
|
+
log.msg(f"[{device}] SSH connection test FAILED, telnet fallback disabled")
|
|
248
|
+
return None
|
|
249
|
+
|
|
250
|
+
reactor.connectTCP(device.nodeName, port, factory)
|
|
251
|
+
# TODO (jathan): There has to be another way than calling Tacacsrc
|
|
252
|
+
# construtor AGAIN...
|
|
253
|
+
print(f"\nFetching credentials from {tacacsrc.Tacacsrc().file_name}")
|
|
254
|
+
|
|
255
|
+
return d
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
login_failed = None
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def handle_login_failure(failure):
|
|
262
|
+
"""
|
|
263
|
+
An errback to try detect a login failure
|
|
264
|
+
|
|
265
|
+
:param failure:
|
|
266
|
+
A Twisted ``Failure`` instance
|
|
267
|
+
"""
|
|
268
|
+
global login_failed
|
|
269
|
+
login_failed = failure
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def connect(
|
|
273
|
+
device,
|
|
274
|
+
init_commands=None,
|
|
275
|
+
output_logger=None,
|
|
276
|
+
login_errback=None,
|
|
277
|
+
reconnect_handler=None,
|
|
278
|
+
):
|
|
279
|
+
"""
|
|
280
|
+
Connect to a network device via pty for an interactive shell.
|
|
281
|
+
|
|
282
|
+
:param device:
|
|
283
|
+
A `~trigger.netdevices.NetDevice` object.
|
|
284
|
+
|
|
285
|
+
:param init_commands:
|
|
286
|
+
(Optional) A list of commands to execute upon logging into the device.
|
|
287
|
+
If not set, they will be attempted to be read from ``.gorc``.
|
|
288
|
+
|
|
289
|
+
:param output_logger:
|
|
290
|
+
(Optional) If set all data received by the device, including user
|
|
291
|
+
input, will be written to this logger. This logger must behave like a
|
|
292
|
+
file-like object and a implement a `.write()` method. Hint: Use
|
|
293
|
+
``StringIO``.
|
|
294
|
+
|
|
295
|
+
:param login_errback:
|
|
296
|
+
(Optional) An callable to be used as an errback that will handle the
|
|
297
|
+
login failure behavior. If not set the default handler will be used.
|
|
298
|
+
|
|
299
|
+
:param reconnect_handler:
|
|
300
|
+
(Optional) A callable to handle the behavior of an authentication
|
|
301
|
+
failure after a login has failed. If not set default handler will be
|
|
302
|
+
used.
|
|
303
|
+
"""
|
|
304
|
+
# Need to pass ^C through to the router so we can abort traceroute, etc.
|
|
305
|
+
print(f"Connecting to {device}. Use ^X to exit.")
|
|
306
|
+
|
|
307
|
+
# Fetch the initial commands for the device
|
|
308
|
+
if init_commands is None:
|
|
309
|
+
from trigger import gorc
|
|
310
|
+
|
|
311
|
+
init_commands = gorc.get_init_commands(device.vendor.name)
|
|
312
|
+
|
|
313
|
+
# Sane defaults
|
|
314
|
+
if login_errback is None:
|
|
315
|
+
login_errback = handle_login_failure
|
|
316
|
+
if reconnect_handler is None:
|
|
317
|
+
reconnect_handler = cli.update_password_and_reconnect
|
|
318
|
+
|
|
319
|
+
try:
|
|
320
|
+
d = pty_connect(
|
|
321
|
+
device, Interactor(log_to=output_logger), init_commands=init_commands
|
|
322
|
+
)
|
|
323
|
+
d.addErrback(login_errback)
|
|
324
|
+
d.addErrback(log.err)
|
|
325
|
+
d.addCallback(lambda x: stop_reactor())
|
|
326
|
+
except AttributeError as err:
|
|
327
|
+
log.msg(err)
|
|
328
|
+
sys.stderr.write(f"Could not connect to {device}.\n")
|
|
329
|
+
return 2 # Bad exit code
|
|
330
|
+
|
|
331
|
+
cli.setup_tty_for_pty(reactor.run)
|
|
332
|
+
|
|
333
|
+
# If there is a login failure stop the reactor so we can take raw_input(),
|
|
334
|
+
# ask the user if they, want to update their cached credentials, and
|
|
335
|
+
# prompt them to connect. Otherwise just display the error message and
|
|
336
|
+
# exit.
|
|
337
|
+
if login_failed is not None:
|
|
338
|
+
stop_reactor()
|
|
339
|
+
|
|
340
|
+
# print '\nLogin failed for the following reason:\n'
|
|
341
|
+
print("\nConnection failed for the following reason:\n")
|
|
342
|
+
print(f"{login_failed.value}\n")
|
|
343
|
+
|
|
344
|
+
if login_failed.type == exceptions.LoginFailure:
|
|
345
|
+
reconnect_handler(device.nodeName)
|
|
346
|
+
|
|
347
|
+
print("BYE")
|
|
348
|
+
|
|
349
|
+
return 0 # Good exit code
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
# ==================
|
|
353
|
+
# Execute Factory functions
|
|
354
|
+
# ==================
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _choose_execute(device, force_cli=False):
|
|
358
|
+
"""
|
|
359
|
+
Return the appropriate execute_ function for the given ``device`` based on
|
|
360
|
+
platform and SSH/Telnet availability.
|
|
361
|
+
|
|
362
|
+
:param device:
|
|
363
|
+
A `~trigger.netdevices.NetDevice` object.
|
|
364
|
+
"""
|
|
365
|
+
if device.is_ioslike():
|
|
366
|
+
_execute = execute_ioslike
|
|
367
|
+
elif device.is_netscaler():
|
|
368
|
+
_execute = execute_netscaler
|
|
369
|
+
elif device.is_netscreen():
|
|
370
|
+
_execute = execute_netscreen
|
|
371
|
+
elif device.vendor == "juniper":
|
|
372
|
+
if force_cli:
|
|
373
|
+
_execute = execute_async_pty_ssh
|
|
374
|
+
else:
|
|
375
|
+
_execute = execute_junoscript
|
|
376
|
+
elif device.is_pica8():
|
|
377
|
+
_execute = execute_pica8
|
|
378
|
+
else:
|
|
379
|
+
_execute = execute_async_pty_ssh
|
|
380
|
+
|
|
381
|
+
return _execute
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def execute(
|
|
385
|
+
device,
|
|
386
|
+
commands,
|
|
387
|
+
creds=None,
|
|
388
|
+
incremental=None,
|
|
389
|
+
with_errors=False,
|
|
390
|
+
timeout=settings.DEFAULT_TIMEOUT,
|
|
391
|
+
command_interval=0,
|
|
392
|
+
force_cli=False,
|
|
393
|
+
):
|
|
394
|
+
"""
|
|
395
|
+
Connect to a ``device`` and sequentially execute all the commands in the
|
|
396
|
+
iterable ``commands``.
|
|
397
|
+
|
|
398
|
+
Returns a Twisted ``Deferred`` object, whose callback will get a sequence
|
|
399
|
+
of all the results after the connection is finished.
|
|
400
|
+
|
|
401
|
+
``commands`` is usually just a list, however, you can have also make it a
|
|
402
|
+
generator, and have it and ``incremental`` share a closure to some state
|
|
403
|
+
variables. This allows you to determine what commands to execute
|
|
404
|
+
dynamically based on the results of previous commands. This implementation
|
|
405
|
+
is experimental and it might be a better idea to have the ``incremental``
|
|
406
|
+
callback determine what command to execute next; it could then be a method
|
|
407
|
+
of an object that keeps state.
|
|
408
|
+
|
|
409
|
+
BEWARE: Your generator cannot block; you must immediately
|
|
410
|
+
decide what next command to execute, if any.
|
|
411
|
+
|
|
412
|
+
Any ``None`` in the command sequence will result in a ``None`` being placed
|
|
413
|
+
in the output sequence, with no command issued to the device.
|
|
414
|
+
|
|
415
|
+
If any command returns an error, the connection is dropped immediately and
|
|
416
|
+
the errback will fire with the failed command. You may set ``with_errors``
|
|
417
|
+
to get the exception objects in the list instead.
|
|
418
|
+
|
|
419
|
+
Connection failures will still fire the errback.
|
|
420
|
+
|
|
421
|
+
`~trigger.exceptions.LoginTimeout` errors are always possible if the login
|
|
422
|
+
process takes longer than expected and cannot be disabled.
|
|
423
|
+
|
|
424
|
+
:param device:
|
|
425
|
+
A `~trigger.netdevices.NetDevice` object
|
|
426
|
+
|
|
427
|
+
:param commands:
|
|
428
|
+
An iterable of commands to execute (without newlines).
|
|
429
|
+
|
|
430
|
+
:param creds:
|
|
431
|
+
(Optional) A 2-tuple of (username, password). If unset it will fetch it
|
|
432
|
+
from ``.tacacsrc``.
|
|
433
|
+
|
|
434
|
+
:param incremental:
|
|
435
|
+
(Optional) A callback that will be called with an empty sequence upon
|
|
436
|
+
connection and then called every time a result comes back from the
|
|
437
|
+
device, with the list of all results.
|
|
438
|
+
|
|
439
|
+
:param with_errors:
|
|
440
|
+
(Optional) Return exceptions as results instead of raising them
|
|
441
|
+
|
|
442
|
+
:param timeout:
|
|
443
|
+
(Optional) Command response timeout in seconds. Set to ``None`` to
|
|
444
|
+
disable. The default is in ``settings.DEFAULT_TIMEOUT``.
|
|
445
|
+
`~trigger.exceptions.CommandTimeout` errors will result if a command
|
|
446
|
+
seems to take longer to return than specified.
|
|
447
|
+
|
|
448
|
+
:param command_interval:
|
|
449
|
+
(Optional) Amount of time in seconds to wait between sending commands.
|
|
450
|
+
|
|
451
|
+
:param force_cli:
|
|
452
|
+
(Optional) Juniper-only: Force use of CLI instead of Junoscript.
|
|
453
|
+
|
|
454
|
+
:returns: A Twisted ``Deferred`` object
|
|
455
|
+
"""
|
|
456
|
+
execute_func = _choose_execute(device, force_cli=force_cli)
|
|
457
|
+
return execute_func(
|
|
458
|
+
device=device,
|
|
459
|
+
commands=commands,
|
|
460
|
+
creds=creds,
|
|
461
|
+
incremental=incremental,
|
|
462
|
+
with_errors=with_errors,
|
|
463
|
+
timeout=timeout,
|
|
464
|
+
command_interval=command_interval,
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def execute_generic_ssh(
|
|
469
|
+
device,
|
|
470
|
+
commands,
|
|
471
|
+
creds=None,
|
|
472
|
+
incremental=None,
|
|
473
|
+
with_errors=False,
|
|
474
|
+
timeout=settings.DEFAULT_TIMEOUT,
|
|
475
|
+
command_interval=0,
|
|
476
|
+
channel_class=None,
|
|
477
|
+
prompt_pattern=None,
|
|
478
|
+
method="Generic",
|
|
479
|
+
connection_class=None,
|
|
480
|
+
):
|
|
481
|
+
"""
|
|
482
|
+
Use default SSH channel to execute commands on a device. Should work with
|
|
483
|
+
anything not wonky.
|
|
484
|
+
|
|
485
|
+
Please see `~trigger.twister.execute` for a full description of the
|
|
486
|
+
arguments and how this works.
|
|
487
|
+
"""
|
|
488
|
+
d = defer.Deferred()
|
|
489
|
+
|
|
490
|
+
# Fallback to sane defaults if they aren't specified
|
|
491
|
+
if channel_class is None:
|
|
492
|
+
channel_class = TriggerSSHGenericChannel
|
|
493
|
+
if prompt_pattern is None:
|
|
494
|
+
prompt_pattern = device.vendor.prompt_pattern
|
|
495
|
+
if connection_class is None:
|
|
496
|
+
connection_class = TriggerSSHConnection
|
|
497
|
+
|
|
498
|
+
factory = TriggerSSHChannelFactory(
|
|
499
|
+
d,
|
|
500
|
+
commands,
|
|
501
|
+
creds,
|
|
502
|
+
incremental,
|
|
503
|
+
with_errors,
|
|
504
|
+
timeout,
|
|
505
|
+
channel_class,
|
|
506
|
+
command_interval,
|
|
507
|
+
prompt_pattern,
|
|
508
|
+
device,
|
|
509
|
+
connection_class,
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
port = device.nodePort or settings.SSH_PORT
|
|
513
|
+
log.msg(f"Trying {method} SSH to {device}:{port}", debug=True)
|
|
514
|
+
reactor.connectTCP(device.nodeName, port, factory)
|
|
515
|
+
return d
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def execute_exec_ssh(
|
|
519
|
+
device,
|
|
520
|
+
commands,
|
|
521
|
+
creds=None,
|
|
522
|
+
incremental=None,
|
|
523
|
+
with_errors=False,
|
|
524
|
+
timeout=settings.DEFAULT_TIMEOUT,
|
|
525
|
+
command_interval=0,
|
|
526
|
+
):
|
|
527
|
+
"""
|
|
528
|
+
Use multiplexed SSH 'exec' command channels to execute commands.
|
|
529
|
+
|
|
530
|
+
This will maintain a single SSH connection and run each new command in a
|
|
531
|
+
separate channel after the previous command completes.
|
|
532
|
+
|
|
533
|
+
Please see `~trigger.twister.execute` for a full description of the
|
|
534
|
+
arguments and how this works.
|
|
535
|
+
"""
|
|
536
|
+
channel_class = TriggerSSHCommandChannel
|
|
537
|
+
prompt_pattern = ""
|
|
538
|
+
method = "Exec"
|
|
539
|
+
connection_class = TriggerSSHMultiplexConnection
|
|
540
|
+
return execute_generic_ssh(
|
|
541
|
+
device,
|
|
542
|
+
commands,
|
|
543
|
+
creds,
|
|
544
|
+
incremental,
|
|
545
|
+
with_errors,
|
|
546
|
+
timeout,
|
|
547
|
+
command_interval,
|
|
548
|
+
channel_class,
|
|
549
|
+
prompt_pattern,
|
|
550
|
+
method,
|
|
551
|
+
connection_class,
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def execute_junoscript(
|
|
556
|
+
device,
|
|
557
|
+
commands,
|
|
558
|
+
creds=None,
|
|
559
|
+
incremental=None,
|
|
560
|
+
with_errors=False,
|
|
561
|
+
timeout=settings.DEFAULT_TIMEOUT,
|
|
562
|
+
command_interval=0,
|
|
563
|
+
):
|
|
564
|
+
"""
|
|
565
|
+
Connect to a Juniper device and enable Junoscript XML mode. All commands
|
|
566
|
+
are expected to be XML commands (ElementTree.Element objects suitable for
|
|
567
|
+
wrapping in ``<rpc>`` elements). Errors are expected to be of type
|
|
568
|
+
``xnm:error``. Note that prompt detection is not used here.
|
|
569
|
+
|
|
570
|
+
Please see `~trigger.twister.execute` for a full description of the
|
|
571
|
+
arguments and how this works.
|
|
572
|
+
"""
|
|
573
|
+
assert device.vendor == "juniper"
|
|
574
|
+
|
|
575
|
+
channel_class = TriggerSSHJunoscriptChannel
|
|
576
|
+
prompt_pattern = ""
|
|
577
|
+
method = "Junoscript"
|
|
578
|
+
return execute_generic_ssh(
|
|
579
|
+
device,
|
|
580
|
+
commands,
|
|
581
|
+
creds,
|
|
582
|
+
incremental,
|
|
583
|
+
with_errors,
|
|
584
|
+
timeout,
|
|
585
|
+
command_interval,
|
|
586
|
+
channel_class,
|
|
587
|
+
prompt_pattern,
|
|
588
|
+
method,
|
|
589
|
+
)
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def execute_ioslike(
|
|
593
|
+
device,
|
|
594
|
+
commands,
|
|
595
|
+
creds=None,
|
|
596
|
+
incremental=None,
|
|
597
|
+
with_errors=False,
|
|
598
|
+
timeout=settings.DEFAULT_TIMEOUT,
|
|
599
|
+
command_interval=0,
|
|
600
|
+
loginpw=None,
|
|
601
|
+
enablepw=None,
|
|
602
|
+
):
|
|
603
|
+
"""
|
|
604
|
+
Execute commands on a Cisco/IOS-like device. It will automatically try to
|
|
605
|
+
connect using SSH if it is available and not disabled in ``settings.py``.
|
|
606
|
+
If SSH is unavailable, it will fallback to telnet unless that is also
|
|
607
|
+
disabled in the settings. Otherwise it will fail, so you should probably
|
|
608
|
+
make sure one or the other is enabled!
|
|
609
|
+
|
|
610
|
+
Please see `~trigger.twister.execute` for a full description of the
|
|
611
|
+
arguments and how this works.
|
|
612
|
+
"""
|
|
613
|
+
# Try SSH if it's available and enabled
|
|
614
|
+
if device.can_ssh_async():
|
|
615
|
+
log.msg(f"execute_ioslike: SSH ENABLED for {device.nodeName}")
|
|
616
|
+
return execute_ioslike_ssh(
|
|
617
|
+
device=device,
|
|
618
|
+
commands=commands,
|
|
619
|
+
creds=creds,
|
|
620
|
+
incremental=incremental,
|
|
621
|
+
with_errors=with_errors,
|
|
622
|
+
timeout=timeout,
|
|
623
|
+
command_interval=command_interval,
|
|
624
|
+
)
|
|
625
|
+
|
|
626
|
+
# Fallback to telnet if it's enabled
|
|
627
|
+
elif settings.TELNET_ENABLED:
|
|
628
|
+
log.msg(f"execute_ioslike: TELNET ENABLED for {device.nodeName}")
|
|
629
|
+
return execute_ioslike_telnet(
|
|
630
|
+
device=device,
|
|
631
|
+
commands=commands,
|
|
632
|
+
creds=creds,
|
|
633
|
+
incremental=incremental,
|
|
634
|
+
with_errors=with_errors,
|
|
635
|
+
timeout=timeout,
|
|
636
|
+
command_interval=command_interval,
|
|
637
|
+
loginpw=loginpw,
|
|
638
|
+
enablepw=enablepw,
|
|
639
|
+
)
|
|
640
|
+
|
|
641
|
+
else:
|
|
642
|
+
msg = "Both SSH and telnet either failed or are disabled."
|
|
643
|
+
log.msg(f"[{device}]", msg)
|
|
644
|
+
e = exceptions.ConnectionFailure(msg)
|
|
645
|
+
return defer.fail(e)
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def execute_ioslike_telnet(
|
|
649
|
+
device,
|
|
650
|
+
commands,
|
|
651
|
+
creds=None,
|
|
652
|
+
incremental=None,
|
|
653
|
+
with_errors=False,
|
|
654
|
+
timeout=settings.DEFAULT_TIMEOUT,
|
|
655
|
+
command_interval=0,
|
|
656
|
+
loginpw=None,
|
|
657
|
+
enablepw=None,
|
|
658
|
+
):
|
|
659
|
+
"""
|
|
660
|
+
Execute commands via telnet on a Cisco/IOS-like device.
|
|
661
|
+
|
|
662
|
+
Please see `~trigger.twister.execute` for a full description of the
|
|
663
|
+
arguments and how this works.
|
|
664
|
+
"""
|
|
665
|
+
assert device.is_ioslike()
|
|
666
|
+
|
|
667
|
+
d = defer.Deferred()
|
|
668
|
+
action = IoslikeSendExpect(
|
|
669
|
+
device, commands, incremental, with_errors, timeout, command_interval
|
|
670
|
+
)
|
|
671
|
+
factory = TriggerTelnetClientFactory(d, action, creds, loginpw, enablepw)
|
|
672
|
+
|
|
673
|
+
port = device.nodePort or settings.TELNET_PORT
|
|
674
|
+
log.msg(f"Trying IOS-like scripting to {device}:{port}", debug=True)
|
|
675
|
+
reactor.connectTCP(device.nodeName, port, factory)
|
|
676
|
+
return d
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def execute_async_pty_ssh(
|
|
680
|
+
device,
|
|
681
|
+
commands,
|
|
682
|
+
creds=None,
|
|
683
|
+
incremental=None,
|
|
684
|
+
with_errors=False,
|
|
685
|
+
timeout=settings.DEFAULT_TIMEOUT,
|
|
686
|
+
command_interval=0,
|
|
687
|
+
prompt_pattern=None,
|
|
688
|
+
):
|
|
689
|
+
"""
|
|
690
|
+
Execute via SSH for a device that requires shell + pty-req.
|
|
691
|
+
|
|
692
|
+
Please see `~trigger.twister.execute` for a full description of the
|
|
693
|
+
arguments and how this works.
|
|
694
|
+
"""
|
|
695
|
+
channel_class = TriggerSSHAsyncPtyChannel
|
|
696
|
+
method = "Async PTY"
|
|
697
|
+
if prompt_pattern is None:
|
|
698
|
+
prompt_pattern = device.vendor.prompt_pattern
|
|
699
|
+
|
|
700
|
+
return execute_generic_ssh(
|
|
701
|
+
device,
|
|
702
|
+
commands,
|
|
703
|
+
creds,
|
|
704
|
+
incremental,
|
|
705
|
+
with_errors,
|
|
706
|
+
timeout,
|
|
707
|
+
command_interval,
|
|
708
|
+
channel_class,
|
|
709
|
+
prompt_pattern,
|
|
710
|
+
method,
|
|
711
|
+
)
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def execute_ioslike_ssh(
|
|
715
|
+
device,
|
|
716
|
+
commands,
|
|
717
|
+
creds=None,
|
|
718
|
+
incremental=None,
|
|
719
|
+
with_errors=False,
|
|
720
|
+
timeout=settings.DEFAULT_TIMEOUT,
|
|
721
|
+
command_interval=0,
|
|
722
|
+
):
|
|
723
|
+
"""
|
|
724
|
+
Execute via SSH for IOS-like devices with some exceptions.
|
|
725
|
+
|
|
726
|
+
Please see `~trigger.twister.execute` for a full description of the
|
|
727
|
+
arguments and how this works.
|
|
728
|
+
"""
|
|
729
|
+
assert device.is_ioslike()
|
|
730
|
+
|
|
731
|
+
# Test if device requires shell + pty-req
|
|
732
|
+
if device.requires_async_pty:
|
|
733
|
+
return execute_async_pty_ssh(
|
|
734
|
+
device, commands, creds, incremental, with_errors, timeout, command_interval
|
|
735
|
+
)
|
|
736
|
+
# Or fallback to generic
|
|
737
|
+
else:
|
|
738
|
+
method = "IOS-like"
|
|
739
|
+
return execute_generic_ssh(
|
|
740
|
+
device,
|
|
741
|
+
commands,
|
|
742
|
+
creds,
|
|
743
|
+
incremental,
|
|
744
|
+
with_errors,
|
|
745
|
+
timeout,
|
|
746
|
+
command_interval,
|
|
747
|
+
method=method,
|
|
748
|
+
)
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
def execute_netscreen(
|
|
752
|
+
device,
|
|
753
|
+
commands,
|
|
754
|
+
creds=None,
|
|
755
|
+
incremental=None,
|
|
756
|
+
with_errors=False,
|
|
757
|
+
timeout=settings.DEFAULT_TIMEOUT,
|
|
758
|
+
command_interval=0,
|
|
759
|
+
):
|
|
760
|
+
"""
|
|
761
|
+
Execute commands on a NetScreen device running ScreenOS. For NetScreen
|
|
762
|
+
devices running Junos, use `~trigger.twister.execute_junoscript`.
|
|
763
|
+
|
|
764
|
+
Please see `~trigger.twister.execute` for a full description of the
|
|
765
|
+
arguments and how this works.
|
|
766
|
+
"""
|
|
767
|
+
assert device.is_netscreen()
|
|
768
|
+
|
|
769
|
+
# We live in a world where not every NetScreen device is local and can use
|
|
770
|
+
# TACACS, so we must store unique credentials for each NetScreen device.
|
|
771
|
+
if not creds:
|
|
772
|
+
creds = tacacsrc.get_device_password(device.nodeName)
|
|
773
|
+
|
|
774
|
+
channel_class = TriggerSSHGenericChannel
|
|
775
|
+
method = "NetScreen"
|
|
776
|
+
prompt_pattern = settings.PROMPT_PATTERNS["netscreen"] # This sucks
|
|
777
|
+
return execute_generic_ssh(
|
|
778
|
+
device,
|
|
779
|
+
commands,
|
|
780
|
+
creds,
|
|
781
|
+
incremental,
|
|
782
|
+
with_errors,
|
|
783
|
+
timeout,
|
|
784
|
+
command_interval,
|
|
785
|
+
channel_class,
|
|
786
|
+
method=method,
|
|
787
|
+
prompt_pattern=prompt_pattern,
|
|
788
|
+
)
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
def execute_netscaler(
|
|
792
|
+
device,
|
|
793
|
+
commands,
|
|
794
|
+
creds=None,
|
|
795
|
+
incremental=None,
|
|
796
|
+
with_errors=False,
|
|
797
|
+
timeout=settings.DEFAULT_TIMEOUT,
|
|
798
|
+
command_interval=0,
|
|
799
|
+
):
|
|
800
|
+
"""
|
|
801
|
+
Execute commands on a NetScaler device.
|
|
802
|
+
|
|
803
|
+
Please see `~trigger.twister.execute` for a full description of the
|
|
804
|
+
arguments and how this works.
|
|
805
|
+
"""
|
|
806
|
+
assert device.is_netscaler()
|
|
807
|
+
|
|
808
|
+
channel_class = TriggerSSHNetscalerChannel
|
|
809
|
+
method = "NetScaler"
|
|
810
|
+
return execute_generic_ssh(
|
|
811
|
+
device,
|
|
812
|
+
commands,
|
|
813
|
+
creds,
|
|
814
|
+
incremental,
|
|
815
|
+
with_errors,
|
|
816
|
+
timeout,
|
|
817
|
+
command_interval,
|
|
818
|
+
channel_class,
|
|
819
|
+
method=method,
|
|
820
|
+
)
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
def execute_pica8(
|
|
824
|
+
device,
|
|
825
|
+
commands,
|
|
826
|
+
creds=None,
|
|
827
|
+
incremental=None,
|
|
828
|
+
with_errors=False,
|
|
829
|
+
timeout=settings.DEFAULT_TIMEOUT,
|
|
830
|
+
command_interval=0,
|
|
831
|
+
):
|
|
832
|
+
"""
|
|
833
|
+
Execute commands on a Pica8 device. This is only needed to append
|
|
834
|
+
'| no-more' to show commands because Pica8 currently (v2.2) lacks
|
|
835
|
+
a global command to disable paging.
|
|
836
|
+
|
|
837
|
+
Please see `~trigger.twister.execute` for a full description of the
|
|
838
|
+
arguments and how this works.
|
|
839
|
+
"""
|
|
840
|
+
assert device.is_pica8()
|
|
841
|
+
|
|
842
|
+
channel_class = TriggerSSHPica8Channel
|
|
843
|
+
method = "Async PTY"
|
|
844
|
+
return execute_generic_ssh(
|
|
845
|
+
device,
|
|
846
|
+
commands,
|
|
847
|
+
creds,
|
|
848
|
+
incremental,
|
|
849
|
+
with_errors,
|
|
850
|
+
timeout,
|
|
851
|
+
command_interval,
|
|
852
|
+
channel_class,
|
|
853
|
+
method=method,
|
|
854
|
+
)
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
# Classes
|
|
858
|
+
# ==================
|
|
859
|
+
# Client Factories
|
|
860
|
+
# ==================
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
class TriggerClientFactory(protocol.ClientFactory):
|
|
864
|
+
"""
|
|
865
|
+
Factory for all clients. Subclass me.
|
|
866
|
+
"""
|
|
867
|
+
|
|
868
|
+
def __init__(self, deferred, creds=None, init_commands=None):
|
|
869
|
+
self.d = deferred
|
|
870
|
+
self.creds = tacacsrc.validate_credentials(creds)
|
|
871
|
+
self.results = []
|
|
872
|
+
self.err = None
|
|
873
|
+
|
|
874
|
+
# Setup and run the initial commands
|
|
875
|
+
if init_commands is None:
|
|
876
|
+
init_commands = [] # We need this to be a list
|
|
877
|
+
self.init_commands = init_commands
|
|
878
|
+
log.msg(f"INITIAL COMMANDS: {self.init_commands!r}", debug=True)
|
|
879
|
+
self.initialized = False
|
|
880
|
+
|
|
881
|
+
def clientConnectionFailed(self, connector, reason):
|
|
882
|
+
"""Do this when the connection fails."""
|
|
883
|
+
log.msg(f"Client connection failed. Reason: {reason}")
|
|
884
|
+
self.d.errback(reason)
|
|
885
|
+
|
|
886
|
+
def clientConnectionLost(self, connector, reason):
|
|
887
|
+
"""Do this when the connection is lost."""
|
|
888
|
+
log.msg(f"Client connection lost. Reason: {reason}")
|
|
889
|
+
if self.err:
|
|
890
|
+
log.msg(f"Got err: {self.err!r}")
|
|
891
|
+
# log.err(self.err)
|
|
892
|
+
self.d.errback(self.err)
|
|
893
|
+
else:
|
|
894
|
+
log.msg(f"Got results: {self.results!r}")
|
|
895
|
+
self.d.callback(self.results)
|
|
896
|
+
|
|
897
|
+
def stopFactory(self):
|
|
898
|
+
# IF we're out of channels, shut it down!
|
|
899
|
+
log.msg("All done!")
|
|
900
|
+
|
|
901
|
+
def _init_commands(self, protocol):
|
|
902
|
+
"""
|
|
903
|
+
Execute any initial commands specified.
|
|
904
|
+
|
|
905
|
+
:param protocol: A Protocol instance (e.g. action) to which to write
|
|
906
|
+
the commands.
|
|
907
|
+
"""
|
|
908
|
+
if not self.initialized:
|
|
909
|
+
log.msg("Not initialized, sending init commands", debug=True)
|
|
910
|
+
for next_init in self.init_commands:
|
|
911
|
+
log.msg(f"Sending: {next_init!r}", debug=True)
|
|
912
|
+
protocol.write(next_init + "\r\n")
|
|
913
|
+
else:
|
|
914
|
+
self.initialized = True
|
|
915
|
+
|
|
916
|
+
def connection_success(self, conn, transport):
|
|
917
|
+
log.msg("Connection success.")
|
|
918
|
+
self.conn = conn
|
|
919
|
+
self.transport = transport
|
|
920
|
+
log.msg(f"Connection information: {self.transport}")
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
class TriggerSSHChannelFactory(TriggerClientFactory):
|
|
924
|
+
"""
|
|
925
|
+
Intended to be used as a parent of automated SSH channels (e.g. Junoscript,
|
|
926
|
+
NetScreen, NetScaler) to eliminate boiler plate in those subclasses.
|
|
927
|
+
"""
|
|
928
|
+
|
|
929
|
+
def __init__(
|
|
930
|
+
self,
|
|
931
|
+
deferred,
|
|
932
|
+
commands,
|
|
933
|
+
creds=None,
|
|
934
|
+
incremental=None,
|
|
935
|
+
with_errors=False,
|
|
936
|
+
timeout=None,
|
|
937
|
+
channel_class=None,
|
|
938
|
+
command_interval=0,
|
|
939
|
+
prompt_pattern=None,
|
|
940
|
+
device=None,
|
|
941
|
+
connection_class=None,
|
|
942
|
+
):
|
|
943
|
+
# Fallback to sane defaults if they aren't specified
|
|
944
|
+
if channel_class is None:
|
|
945
|
+
channel_class = TriggerSSHGenericChannel
|
|
946
|
+
if connection_class is None:
|
|
947
|
+
connection_class = TriggerSSHConnection
|
|
948
|
+
if prompt_pattern is None:
|
|
949
|
+
prompt_pattern = settings.DEFAULT_PROMPT_PAT
|
|
950
|
+
|
|
951
|
+
self.protocol = TriggerSSHTransport
|
|
952
|
+
self.display_banner = None
|
|
953
|
+
self.commands = commands
|
|
954
|
+
self.commanditer = iter(commands)
|
|
955
|
+
self.initialized = False
|
|
956
|
+
self.incremental = incremental
|
|
957
|
+
self.with_errors = with_errors
|
|
958
|
+
self.timeout = timeout
|
|
959
|
+
self.channel_class = channel_class
|
|
960
|
+
self.command_interval = command_interval
|
|
961
|
+
self.prompt = re.compile(prompt_pattern)
|
|
962
|
+
self.device = device
|
|
963
|
+
self.connection_class = connection_class
|
|
964
|
+
TriggerClientFactory.__init__(self, deferred, creds)
|
|
965
|
+
|
|
966
|
+
def buildProtocol(self, addr):
|
|
967
|
+
self.protocol = self.protocol()
|
|
968
|
+
self.protocol.factory = self
|
|
969
|
+
return self.protocol
|
|
970
|
+
|
|
971
|
+
|
|
972
|
+
class TriggerSSHPtyClientFactory(TriggerClientFactory):
|
|
973
|
+
"""
|
|
974
|
+
Factory for an interactive SSH connection.
|
|
975
|
+
|
|
976
|
+
'action' is a Protocol that will be connected to the session after login.
|
|
977
|
+
Use it to interact with the user and pass along commands.
|
|
978
|
+
"""
|
|
979
|
+
|
|
980
|
+
def __init__(
|
|
981
|
+
self,
|
|
982
|
+
deferred,
|
|
983
|
+
action,
|
|
984
|
+
creds=None,
|
|
985
|
+
display_banner=None,
|
|
986
|
+
init_commands=None,
|
|
987
|
+
device=None,
|
|
988
|
+
):
|
|
989
|
+
self.protocol = TriggerSSHTransport
|
|
990
|
+
self.action = action
|
|
991
|
+
self.action.factory = self
|
|
992
|
+
self.device = device
|
|
993
|
+
self.display_banner = display_banner
|
|
994
|
+
self.channel_class = TriggerSSHPtyChannel
|
|
995
|
+
self.connection_class = TriggerSSHConnection
|
|
996
|
+
self.commands = []
|
|
997
|
+
self.command_interval = 0
|
|
998
|
+
TriggerClientFactory.__init__(self, deferred, creds, init_commands)
|
|
999
|
+
|
|
1000
|
+
|
|
1001
|
+
# ==================
|
|
1002
|
+
# SSH Basics
|
|
1003
|
+
# ==================
|
|
1004
|
+
|
|
1005
|
+
|
|
1006
|
+
class TriggerSSHTransport(transport.SSHClientTransport):
|
|
1007
|
+
"""
|
|
1008
|
+
SSH transport with Trigger's defaults.
|
|
1009
|
+
|
|
1010
|
+
Call with magic factory attributes ``creds``, a tuple of login
|
|
1011
|
+
credentials, and ``connection_class``, the class of channel to open, and
|
|
1012
|
+
``commands``, the list of commands to pass to the connection.
|
|
1013
|
+
"""
|
|
1014
|
+
|
|
1015
|
+
def verifyHostKey(self, pubKey, fingerprint):
|
|
1016
|
+
"""Verify host key, but don't actually verify. Awesome."""
|
|
1017
|
+
return defer.succeed(True)
|
|
1018
|
+
|
|
1019
|
+
def connectionMade(self):
|
|
1020
|
+
"""
|
|
1021
|
+
Once the connection is up, set the ciphers but don't do anything else!
|
|
1022
|
+
"""
|
|
1023
|
+
self.currentEncryptions = transport.SSHCiphers("none", "none", "none", "none")
|
|
1024
|
+
self.currentEncryptions.setKeys("", "", "", "", "", "")
|
|
1025
|
+
|
|
1026
|
+
# FIXME(jathan): Make sure that this isn't causing a regression to:
|
|
1027
|
+
# https://github.com/trigger/trigger/pull/198
|
|
1028
|
+
def dataReceived(self, data):
|
|
1029
|
+
"""
|
|
1030
|
+
Explicity override version detection for edge cases where "SSH-"
|
|
1031
|
+
isn't on the first line of incoming data.
|
|
1032
|
+
"""
|
|
1033
|
+
# Store incoming data in a local buffer until we've detected the
|
|
1034
|
+
# presence of 'SSH-', then handover to default .dataReceived() for
|
|
1035
|
+
# version banner processing.
|
|
1036
|
+
if not hasattr(self, "my_buf"):
|
|
1037
|
+
self.my_buf = ""
|
|
1038
|
+
self.my_buf = self.my_buf + data
|
|
1039
|
+
|
|
1040
|
+
preVersion = self.gotVersion
|
|
1041
|
+
|
|
1042
|
+
# One extra loop should be enough to get the banner to come through.
|
|
1043
|
+
if not self.gotVersion and b"SSH-" not in self.my_buf:
|
|
1044
|
+
return
|
|
1045
|
+
|
|
1046
|
+
# This call should populate the SSH version and carry on as usual.
|
|
1047
|
+
transport.SSHClientTransport.dataReceived(self, data)
|
|
1048
|
+
|
|
1049
|
+
# We have now seen the SSH version in the banner.
|
|
1050
|
+
# signal that the connection has been made successfully.
|
|
1051
|
+
if self.gotVersion and not preVersion:
|
|
1052
|
+
transport.SSHClientTransport.connectionMade(self)
|
|
1053
|
+
|
|
1054
|
+
def connectionSecure(self):
|
|
1055
|
+
"""Once we're secure, authenticate."""
|
|
1056
|
+
# The default SSHUserAuth requires options to be set.
|
|
1057
|
+
options = Options()
|
|
1058
|
+
options.identitys = None # Let it use defaults
|
|
1059
|
+
options["noagent"] = None # Use ssh-agent if SSH_AUTH_SOCK is set
|
|
1060
|
+
ua = TriggerSSHUserAuth(
|
|
1061
|
+
self.factory.creds.username,
|
|
1062
|
+
options,
|
|
1063
|
+
self.factory.connection_class(self.factory.commands),
|
|
1064
|
+
)
|
|
1065
|
+
self.requestService(ua)
|
|
1066
|
+
|
|
1067
|
+
def receiveError(self, reason, desc):
|
|
1068
|
+
"""Do this when we receive an error."""
|
|
1069
|
+
log.msg(f"Received an error, reason: {reason}, desc: {desc})")
|
|
1070
|
+
self.sendDisconnect(reason, desc)
|
|
1071
|
+
|
|
1072
|
+
def connectionLost(self, reason):
|
|
1073
|
+
"""
|
|
1074
|
+
Detect when the transport connection is lost, such as when the
|
|
1075
|
+
remote end closes the connection prematurely (hosts.allow, etc.)
|
|
1076
|
+
"""
|
|
1077
|
+
super().connectionLost(reason)
|
|
1078
|
+
log.msg(f"Transport connection lost: {reason.value}")
|
|
1079
|
+
|
|
1080
|
+
def sendDisconnect(self, reason, desc):
|
|
1081
|
+
"""Trigger disconnect of the transport."""
|
|
1082
|
+
log.msg(f"Got disconnect request, reason: {reason!r}, desc: {desc!r}")
|
|
1083
|
+
|
|
1084
|
+
# Only throw an error if this wasn't user-initiated (reason: 10)
|
|
1085
|
+
if reason == transport.DISCONNECT_CONNECTION_LOST:
|
|
1086
|
+
pass
|
|
1087
|
+
# Protocol errors should result in login failures
|
|
1088
|
+
elif reason == transport.DISCONNECT_PROTOCOL_ERROR:
|
|
1089
|
+
self.factory.err = exceptions.LoginFailure(desc)
|
|
1090
|
+
# Fallback to connection lost
|
|
1091
|
+
else:
|
|
1092
|
+
# Emulate the most common OpenSSH reason for this to happen
|
|
1093
|
+
if reason == transport.DISCONNECT_HOST_NOT_ALLOWED_TO_CONNECT:
|
|
1094
|
+
desc = "ssh_exchange_identification: Connection closed by remote host"
|
|
1095
|
+
self.factory.err = exceptions.SSHConnectionLost(reason, desc)
|
|
1096
|
+
|
|
1097
|
+
super().sendDisconnect(reason, desc)
|
|
1098
|
+
|
|
1099
|
+
|
|
1100
|
+
class TriggerSSHUserAuth(SSHUserAuthClient):
|
|
1101
|
+
"""Perform user authentication over SSH."""
|
|
1102
|
+
|
|
1103
|
+
# The preferred order in which SSH authentication methods are tried.
|
|
1104
|
+
preferredOrder = settings.SSH_AUTHENTICATION_ORDER
|
|
1105
|
+
|
|
1106
|
+
def getPassword(self, prompt=None):
|
|
1107
|
+
"""Send along the password."""
|
|
1108
|
+
log.msg("Performing password authentication", debug=True)
|
|
1109
|
+
return defer.succeed(self.transport.factory.creds.password)
|
|
1110
|
+
|
|
1111
|
+
def getGenericAnswers(self, name, information, prompts):
|
|
1112
|
+
"""
|
|
1113
|
+
Send along the password when authentication mechanism is not 'password'
|
|
1114
|
+
This is most commonly the case with 'keyboard-interactive', which even
|
|
1115
|
+
when configured within self.preferredOrder, does not work using default
|
|
1116
|
+
getPassword() method.
|
|
1117
|
+
"""
|
|
1118
|
+
log.msg("Performing interactive authentication", debug=True)
|
|
1119
|
+
log.msg(f"Prompts: {prompts!r}", debug=True)
|
|
1120
|
+
|
|
1121
|
+
# The response must always a sequence, and the length must match that
|
|
1122
|
+
# of the prompts list
|
|
1123
|
+
response = [""] * len(prompts)
|
|
1124
|
+
for idx, prompt_tuple in enumerate(prompts):
|
|
1125
|
+
prompt, echo = prompt_tuple # e.g. [('Password: ', False)]
|
|
1126
|
+
if "assword" in prompt:
|
|
1127
|
+
log.msg(
|
|
1128
|
+
f"Got password prompt: {prompt!r}, sending password!", debug=True
|
|
1129
|
+
)
|
|
1130
|
+
response[idx] = self.transport.factory.creds.password
|
|
1131
|
+
|
|
1132
|
+
return defer.succeed(response)
|
|
1133
|
+
|
|
1134
|
+
def ssh_USERAUTH_BANNER(self, packet):
|
|
1135
|
+
"""Display SSH banner."""
|
|
1136
|
+
if self.transport.factory.display_banner:
|
|
1137
|
+
banner, language = common.getNS(packet)
|
|
1138
|
+
self.transport.factory.display_banner(banner, language)
|
|
1139
|
+
|
|
1140
|
+
def ssh_USERAUTH_FAILURE(self, packet):
|
|
1141
|
+
"""
|
|
1142
|
+
An almost exact duplicate of SSHUserAuthClient.ssh_USERAUTH_FAILURE
|
|
1143
|
+
modified to forcefully disconnect. If we receive authentication
|
|
1144
|
+
failures, instead of looping until the server boots us and performing a
|
|
1145
|
+
sendDisconnect(), we raise a `~trigger.exceptions.LoginFailure` and
|
|
1146
|
+
call loseConnection().
|
|
1147
|
+
|
|
1148
|
+
See the base docstring for the method signature.
|
|
1149
|
+
"""
|
|
1150
|
+
canContinue, partial = common.getNS(packet)
|
|
1151
|
+
partial = ord(partial)
|
|
1152
|
+
log.msg(f"Previous method: {self.lastAuth!r} ", debug=True)
|
|
1153
|
+
|
|
1154
|
+
# If the last method succeeded, track it. If network devices ever start
|
|
1155
|
+
# doing second-factor authentication this might be useful.
|
|
1156
|
+
if partial:
|
|
1157
|
+
self.authenticatedWith.append(self.lastAuth)
|
|
1158
|
+
# If it failed, track that too...
|
|
1159
|
+
else:
|
|
1160
|
+
log.msg("Previous method failed, skipping it...", debug=True)
|
|
1161
|
+
self.authenticatedWith.append(self.lastAuth)
|
|
1162
|
+
|
|
1163
|
+
def orderByPreference(meth):
|
|
1164
|
+
"""
|
|
1165
|
+
Invoked once per authentication method in order to extract a
|
|
1166
|
+
comparison key which is then used for sorting.
|
|
1167
|
+
|
|
1168
|
+
@param meth: the authentication method.
|
|
1169
|
+
@type meth: C{str}
|
|
1170
|
+
|
|
1171
|
+
@return: the comparison key for C{meth}.
|
|
1172
|
+
@rtype: C{int}
|
|
1173
|
+
"""
|
|
1174
|
+
if meth in self.preferredOrder:
|
|
1175
|
+
return self.preferredOrder.index(meth)
|
|
1176
|
+
else:
|
|
1177
|
+
# put the element at the end of the list.
|
|
1178
|
+
return len(self.preferredOrder)
|
|
1179
|
+
|
|
1180
|
+
canContinue = sorted(
|
|
1181
|
+
[
|
|
1182
|
+
meth
|
|
1183
|
+
for meth in canContinue.split(",")
|
|
1184
|
+
if meth not in self.authenticatedWith
|
|
1185
|
+
],
|
|
1186
|
+
key=orderByPreference,
|
|
1187
|
+
)
|
|
1188
|
+
|
|
1189
|
+
log.msg(f"Can continue with: {canContinue}")
|
|
1190
|
+
log.msg(f"Already tried: {self.authenticatedWith}", debug=True)
|
|
1191
|
+
return self._cbUserauthFailure(None, iter(canContinue))
|
|
1192
|
+
|
|
1193
|
+
def _cbUserauthFailure(self, result, iterator):
|
|
1194
|
+
"""Callback for ssh_USERAUTH_FAILURE"""
|
|
1195
|
+
if result:
|
|
1196
|
+
return
|
|
1197
|
+
try:
|
|
1198
|
+
method = iterator.next()
|
|
1199
|
+
except StopIteration:
|
|
1200
|
+
msg = (
|
|
1201
|
+
"No more authentication methods available.\n"
|
|
1202
|
+
f"Tried: {self.preferredOrder}\n"
|
|
1203
|
+
"If not using ssh-agent w/ public key, make sure "
|
|
1204
|
+
"SSH_AUTH_SOCK is not set and try again.\n"
|
|
1205
|
+
)
|
|
1206
|
+
self.transport.factory.err = exceptions.LoginFailure(msg)
|
|
1207
|
+
self.transport.loseConnection()
|
|
1208
|
+
else:
|
|
1209
|
+
d = defer.maybeDeferred(self.tryAuth, method)
|
|
1210
|
+
d.addCallback(self._cbUserauthFailure, iterator)
|
|
1211
|
+
return d
|
|
1212
|
+
|
|
1213
|
+
|
|
1214
|
+
class TriggerSSHConnection(SSHConnection):
|
|
1215
|
+
"""
|
|
1216
|
+
Used to manage, you know, an SSH connection.
|
|
1217
|
+
|
|
1218
|
+
Optionally takes a list of commands that may be passed on.
|
|
1219
|
+
"""
|
|
1220
|
+
|
|
1221
|
+
def __init__(self, commands=None, *args, **kwargs):
|
|
1222
|
+
super().__init__()
|
|
1223
|
+
self.commands = commands
|
|
1224
|
+
|
|
1225
|
+
def serviceStarted(self):
|
|
1226
|
+
"""Open the channel once we start."""
|
|
1227
|
+
log.msg(f"channel = {self.transport.factory.channel_class!r}")
|
|
1228
|
+
self.channel_class = self.transport.factory.channel_class
|
|
1229
|
+
self.command_interval = self.transport.factory.command_interval
|
|
1230
|
+
self.transport.factory.connection_success(self, self.transport)
|
|
1231
|
+
|
|
1232
|
+
# Abstracted out so we can do custom stuff with self.openChannel
|
|
1233
|
+
self._channelOpener()
|
|
1234
|
+
|
|
1235
|
+
def _channelOpener(self):
|
|
1236
|
+
"""This is what calls ``self.channelOpen()``"""
|
|
1237
|
+
# Default behavior: Single channel/conn
|
|
1238
|
+
self.openChannel(self.channel_class(conn=self))
|
|
1239
|
+
|
|
1240
|
+
def channelClosed(self, channel):
|
|
1241
|
+
"""
|
|
1242
|
+
Forcefully close the transport connection when a channel closes
|
|
1243
|
+
connection. This is assuming only one channel is open.
|
|
1244
|
+
"""
|
|
1245
|
+
log.msg("Forcefully closing transport connection!")
|
|
1246
|
+
self.transport.loseConnection()
|
|
1247
|
+
|
|
1248
|
+
|
|
1249
|
+
class TriggerSSHMultiplexConnection(TriggerSSHConnection):
|
|
1250
|
+
"""
|
|
1251
|
+
Used for multiplexing SSH 'exec' channels on a single connection.
|
|
1252
|
+
|
|
1253
|
+
Opens a new channel for each command in the stack once the previous channel
|
|
1254
|
+
has closed. In this pattern the Connection and the Channel are intertwined.
|
|
1255
|
+
"""
|
|
1256
|
+
|
|
1257
|
+
def _channelOpener(self):
|
|
1258
|
+
log.msg("Multiplex connection started")
|
|
1259
|
+
self.work = list(self.commands) # Make sure this is a list :)
|
|
1260
|
+
self.send_command()
|
|
1261
|
+
|
|
1262
|
+
def channelClosed(self, channel):
|
|
1263
|
+
"""
|
|
1264
|
+
Close the channel when we're done. But not the transport connection
|
|
1265
|
+
"""
|
|
1266
|
+
log.msg(f"CHANNEL {channel.id} closed")
|
|
1267
|
+
SSHConnection.channelClosed(self, channel)
|
|
1268
|
+
|
|
1269
|
+
def send_command(self):
|
|
1270
|
+
"""
|
|
1271
|
+
Send the next command in the stack once the previous channel has closed
|
|
1272
|
+
"""
|
|
1273
|
+
try:
|
|
1274
|
+
command = self.work.pop(0)
|
|
1275
|
+
except IndexError:
|
|
1276
|
+
log.msg("ALL COMMANDS HAVE FINISHED!")
|
|
1277
|
+
return None
|
|
1278
|
+
|
|
1279
|
+
def command_completed(result, chan):
|
|
1280
|
+
log.msg(f"Command completed: {chan.command!r}")
|
|
1281
|
+
return result
|
|
1282
|
+
|
|
1283
|
+
def command_failed(failure, chan):
|
|
1284
|
+
log.msg(f"Command failed: {chan.command!r}")
|
|
1285
|
+
return failure
|
|
1286
|
+
|
|
1287
|
+
def log_status(result):
|
|
1288
|
+
log.msg(f"COMMANDS LEN: {len(self.commands)}")
|
|
1289
|
+
log.msg(f" RESULTS LEN: {len(self.transport.factory.results)}")
|
|
1290
|
+
return result
|
|
1291
|
+
|
|
1292
|
+
log.msg(f"SENDING NEXT COMMAND: {command}")
|
|
1293
|
+
|
|
1294
|
+
# Send the command to the channel
|
|
1295
|
+
chan = self.channel_class(command, conn=self)
|
|
1296
|
+
|
|
1297
|
+
d = defer.Deferred()
|
|
1298
|
+
reactor.callLater(self.command_interval, d.callback, self.openChannel(chan))
|
|
1299
|
+
d.addCallback(command_completed, chan)
|
|
1300
|
+
d.addErrback(command_failed, chan)
|
|
1301
|
+
d.addBoth(log_status)
|
|
1302
|
+
return d
|
|
1303
|
+
|
|
1304
|
+
|
|
1305
|
+
# ==================
|
|
1306
|
+
# SSH PTY Stuff
|
|
1307
|
+
# ==================
|
|
1308
|
+
|
|
1309
|
+
|
|
1310
|
+
class Interactor(protocol.Protocol):
|
|
1311
|
+
"""
|
|
1312
|
+
Creates an interactive shell.
|
|
1313
|
+
|
|
1314
|
+
Intended for use as an action with pty_connect(). See gong for an example.
|
|
1315
|
+
"""
|
|
1316
|
+
|
|
1317
|
+
def __init__(self, log_to=None):
|
|
1318
|
+
self._log_to = log_to
|
|
1319
|
+
self.enable_prompt = re.compile(settings.IOSLIKE_ENABLE_PAT)
|
|
1320
|
+
self.enabled = False
|
|
1321
|
+
self.initialized = False
|
|
1322
|
+
|
|
1323
|
+
def _log(self, data):
|
|
1324
|
+
if self._log_to is not None:
|
|
1325
|
+
self._log_to.write(data)
|
|
1326
|
+
|
|
1327
|
+
def connectionMade(self):
|
|
1328
|
+
"""Fire up stdin/stdout once we connect."""
|
|
1329
|
+
c = protocol.Protocol()
|
|
1330
|
+
c.dataReceived = self.write
|
|
1331
|
+
self.stdio = stdio.StandardIO(c)
|
|
1332
|
+
self.device = self.factory.device # Attach the device object
|
|
1333
|
+
self.prompt = re.compile(self.device.vendor.prompt_pattern)
|
|
1334
|
+
|
|
1335
|
+
def loseConnection(self):
|
|
1336
|
+
"""
|
|
1337
|
+
Terminate the connection. Link this to the transport method of the same
|
|
1338
|
+
name.
|
|
1339
|
+
"""
|
|
1340
|
+
log.msg(f"[{self.device}] Forcefully closing transport connection")
|
|
1341
|
+
self.factory.transport.loseConnection()
|
|
1342
|
+
|
|
1343
|
+
def dataReceived(self, data):
|
|
1344
|
+
"""And write data to the terminal."""
|
|
1345
|
+
# -- Left during debugging. Enable on ASA not fixed here yet -- #
|
|
1346
|
+
# [2015-08-23] Think this isn't needed, keeping for reference?
|
|
1347
|
+
# log.msg('[%s] DATA: %r' % (self.device, data))
|
|
1348
|
+
# if requires_enable(self, data):
|
|
1349
|
+
# log.msg('[%s] Device Requires Enable: %s' % (
|
|
1350
|
+
# self.device,
|
|
1351
|
+
# requires_enable(self, data)))
|
|
1352
|
+
# log.msg('[%s] Is Device Currently Enabled: %s' % (
|
|
1353
|
+
# self.device,
|
|
1354
|
+
# self.enabled))
|
|
1355
|
+
|
|
1356
|
+
# Check whether we need to send an enable password.
|
|
1357
|
+
if not self.enabled and requires_enable(self, data):
|
|
1358
|
+
log.msg(f"[{self.device}] Interactive PTY requires enable commands")
|
|
1359
|
+
send_enable(self, disconnect_on_fail=False) # Don't exit on fail
|
|
1360
|
+
|
|
1361
|
+
# Setup and run the initial commands, and also assume we're enabled
|
|
1362
|
+
if data and not self.initialized:
|
|
1363
|
+
# Wait for a prompt of some sort to become available before we send
|
|
1364
|
+
# init commands.
|
|
1365
|
+
if self.prompt.search(data):
|
|
1366
|
+
# log.msg('[%s] PROMPT MATCHED: %r' % (self.device, data))
|
|
1367
|
+
self.enabled = True # Forcefully set enable
|
|
1368
|
+
self.factory._init_commands(protocol=self)
|
|
1369
|
+
self.initialized = True
|
|
1370
|
+
|
|
1371
|
+
self._log(data)
|
|
1372
|
+
self.stdio.write(data)
|
|
1373
|
+
|
|
1374
|
+
|
|
1375
|
+
class TriggerSSHPtyChannel(channel.SSHChannel):
|
|
1376
|
+
"""
|
|
1377
|
+
Used by pty_connect() to turn up an interactive SSH pty channel.
|
|
1378
|
+
"""
|
|
1379
|
+
|
|
1380
|
+
name = "session"
|
|
1381
|
+
|
|
1382
|
+
def channelOpen(self, data):
|
|
1383
|
+
"""Setup the terminal when the channel opens."""
|
|
1384
|
+
pr = session.packRequest_pty_req(
|
|
1385
|
+
settings.TERM_TYPE, self._get_window_size(), ""
|
|
1386
|
+
)
|
|
1387
|
+
self.conn.sendRequest(self, "pty-req", pr)
|
|
1388
|
+
self.conn.sendRequest(self, "shell", "")
|
|
1389
|
+
signal.signal(signal.SIGWINCH, self._window_resized)
|
|
1390
|
+
|
|
1391
|
+
# Pass control to the action.
|
|
1392
|
+
self.factory = self.conn.transport.factory
|
|
1393
|
+
action = self.factory.action
|
|
1394
|
+
action.write = self.write
|
|
1395
|
+
self.dataReceived = action.dataReceived
|
|
1396
|
+
self.extReceived = action.dataReceived
|
|
1397
|
+
self.connectionLost = action.connectionLost
|
|
1398
|
+
action.connectionMade()
|
|
1399
|
+
action.dataReceived(data)
|
|
1400
|
+
|
|
1401
|
+
def _window_resized(self, *args):
|
|
1402
|
+
"""Triggered when the terminal is rezied."""
|
|
1403
|
+
win_size = self._get_window_size()
|
|
1404
|
+
new_size = win_size[1], win_size[0], win_size[2], win_size[3]
|
|
1405
|
+
self.conn.sendRequest(self, "window-change", struct.pack("!4L", *new_size))
|
|
1406
|
+
|
|
1407
|
+
def _get_window_size(self):
|
|
1408
|
+
"""Measure the terminal."""
|
|
1409
|
+
stdin_fileno = sys.stdin.fileno()
|
|
1410
|
+
winsz = fcntl.ioctl(stdin_fileno, tty.TIOCGWINSZ, "12345678")
|
|
1411
|
+
return struct.unpack("4H", winsz)
|
|
1412
|
+
|
|
1413
|
+
|
|
1414
|
+
# ==================
|
|
1415
|
+
# SSH Channels
|
|
1416
|
+
# ==================
|
|
1417
|
+
|
|
1418
|
+
|
|
1419
|
+
class TriggerSSHChannelBase(channel.SSHChannel, TimeoutMixin):
|
|
1420
|
+
"""
|
|
1421
|
+
Base class for SSH channels.
|
|
1422
|
+
|
|
1423
|
+
The method self._setup_channelOpen() should be called by channelOpen() in
|
|
1424
|
+
the subclasses. Before you subclass, however, see if you can't just use
|
|
1425
|
+
TriggerSSHGenericChannel as-is!
|
|
1426
|
+
"""
|
|
1427
|
+
|
|
1428
|
+
name = "session"
|
|
1429
|
+
|
|
1430
|
+
def _setup_channelOpen(self):
|
|
1431
|
+
"""
|
|
1432
|
+
Call me in your subclass in self.channelOpen()::
|
|
1433
|
+
|
|
1434
|
+
def channelOpen(self, data):
|
|
1435
|
+
self._setup_channelOpen()
|
|
1436
|
+
self.conn.sendRequest(self, 'shell', '')
|
|
1437
|
+
# etc.
|
|
1438
|
+
"""
|
|
1439
|
+
self.factory = self.conn.transport.factory
|
|
1440
|
+
self.commanditer = self.factory.commanditer
|
|
1441
|
+
self.results = self.factory.results
|
|
1442
|
+
self.with_errors = self.factory.with_errors
|
|
1443
|
+
self.incremental = self.factory.incremental
|
|
1444
|
+
self.command_interval = self.factory.command_interval
|
|
1445
|
+
self.prompt = self.factory.prompt
|
|
1446
|
+
self.setTimeout(self.factory.timeout)
|
|
1447
|
+
self.device = self.factory.device
|
|
1448
|
+
log.msg(f"[{self.device}] COMMANDS: {self.factory.commands!r}")
|
|
1449
|
+
self.data = ""
|
|
1450
|
+
self.initialized = self.factory.initialized
|
|
1451
|
+
self.startup_commands = copy.copy(self.device.startup_commands)
|
|
1452
|
+
log.msg(f"[{self.device}] My startup commands: {self.startup_commands!r}")
|
|
1453
|
+
|
|
1454
|
+
# For IOS-like devices that require 'enable'
|
|
1455
|
+
self.enable_prompt = re.compile(settings.IOSLIKE_ENABLE_PAT)
|
|
1456
|
+
self.enabled = False
|
|
1457
|
+
|
|
1458
|
+
def channelOpen(self, data):
|
|
1459
|
+
"""Do this when the channel opens."""
|
|
1460
|
+
self._setup_channelOpen()
|
|
1461
|
+
d = self.conn.sendRequest(self, "shell", "", wantReply=True)
|
|
1462
|
+
d.addCallback(self._gotResponse)
|
|
1463
|
+
d.addErrback(self._ebShellOpen)
|
|
1464
|
+
|
|
1465
|
+
# Don't call _send_next() here, since we (might) expect to see a
|
|
1466
|
+
# prompt, which will kick off initialization.
|
|
1467
|
+
|
|
1468
|
+
def _gotResponse(self, response):
|
|
1469
|
+
"""
|
|
1470
|
+
Potentially useful if you want to do something after the shell is
|
|
1471
|
+
initialized.
|
|
1472
|
+
|
|
1473
|
+
If the shell never establishes, this won't be called.
|
|
1474
|
+
"""
|
|
1475
|
+
log.msg(f"[{self.device}] Got channel request response!")
|
|
1476
|
+
|
|
1477
|
+
def _ebShellOpen(self, reason):
|
|
1478
|
+
log.msg(f"[{self.device}] Channel request failed: {reason}")
|
|
1479
|
+
|
|
1480
|
+
def dataReceived(self, bytes):
|
|
1481
|
+
"""Do this when we receive data."""
|
|
1482
|
+
# Append to the data buffer
|
|
1483
|
+
self.data += bytes
|
|
1484
|
+
log.msg(f"[{self.device}] BYTES: {bytes!r}")
|
|
1485
|
+
# log.msg('BYTES: (left: %r, max: %r, bytes: %r, data: %r)' %
|
|
1486
|
+
# (self.remoteWindowLeft, self.localMaxPacket, len(bytes),
|
|
1487
|
+
# len(self.data)))
|
|
1488
|
+
|
|
1489
|
+
# Keep going til you get a prompt match
|
|
1490
|
+
m = self.prompt.search(self.data)
|
|
1491
|
+
if not m:
|
|
1492
|
+
# Do we need to send an enable password?
|
|
1493
|
+
if not self.enabled and requires_enable(self, self.data):
|
|
1494
|
+
send_enable(self)
|
|
1495
|
+
return None
|
|
1496
|
+
|
|
1497
|
+
# Check for confirmation prompts
|
|
1498
|
+
# If the prompt confirms set the index to the matched bytes
|
|
1499
|
+
if is_awaiting_confirmation(self.data):
|
|
1500
|
+
log.msg(f"[{self.device}] Got confirmation prompt: {self.data!r}")
|
|
1501
|
+
prompt_idx = self.data.find(bytes)
|
|
1502
|
+
else:
|
|
1503
|
+
return None
|
|
1504
|
+
else:
|
|
1505
|
+
# Or just use the matched regex object...
|
|
1506
|
+
log.msg(f"[{self.device}] STATE: buffer {self.data!r}")
|
|
1507
|
+
log.msg(f"[{self.device}] STATE: prompt {m.group()!r}")
|
|
1508
|
+
prompt_idx = m.start()
|
|
1509
|
+
|
|
1510
|
+
# Strip the prompt from the match result
|
|
1511
|
+
result = self.data[:prompt_idx] # Cut the prompt out
|
|
1512
|
+
result = result[result.find("\n") + 1 :] # Keep all from first newline
|
|
1513
|
+
log.msg(f"[{self.device}] STATE: result {result!r}")
|
|
1514
|
+
|
|
1515
|
+
# Only keep the results once we've sent any startup_commands
|
|
1516
|
+
if self.initialized:
|
|
1517
|
+
self.results.append(result)
|
|
1518
|
+
|
|
1519
|
+
# By default we're checking for IOS-like or Juniper errors because most
|
|
1520
|
+
# vendors # fall under this category.
|
|
1521
|
+
has_errors = has_ioslike_error(result) or has_juniper_error(result)
|
|
1522
|
+
if has_errors and not self.with_errors:
|
|
1523
|
+
log.msg(f"[{self.device}] Command failed: {result!r}")
|
|
1524
|
+
self.factory.err = exceptions.CommandFailure(result)
|
|
1525
|
+
self.loseConnection()
|
|
1526
|
+
return None
|
|
1527
|
+
|
|
1528
|
+
# Honor the command_interval and then send the next command
|
|
1529
|
+
else:
|
|
1530
|
+
if self.command_interval:
|
|
1531
|
+
log.msg(
|
|
1532
|
+
f"[{self.device}] Waiting {self.command_interval} seconds before sending next command"
|
|
1533
|
+
)
|
|
1534
|
+
self.data = "" # Flush the buffer before next command
|
|
1535
|
+
reactor.callLater(self.command_interval, self._send_next)
|
|
1536
|
+
|
|
1537
|
+
def _send_next(self):
|
|
1538
|
+
"""Send the next command in the stack."""
|
|
1539
|
+
self.resetTimeout() # Reset the timeout
|
|
1540
|
+
|
|
1541
|
+
if not self.initialized:
|
|
1542
|
+
log.msg(f"[{self.device}] Not initialized; sending startup commands")
|
|
1543
|
+
if self.startup_commands:
|
|
1544
|
+
next_init = self.startup_commands.pop(0)
|
|
1545
|
+
log.msg(f"[{self.device}] Sending initialize command: {next_init!r}")
|
|
1546
|
+
self.write(next_init.strip() + self.device.delimiter)
|
|
1547
|
+
return None
|
|
1548
|
+
else:
|
|
1549
|
+
log.msg(
|
|
1550
|
+
f"[{self.device}] Successfully initialized for command execution"
|
|
1551
|
+
)
|
|
1552
|
+
self.initialized = True
|
|
1553
|
+
self.enabled = True # Disable further enable checks.
|
|
1554
|
+
|
|
1555
|
+
if self.incremental:
|
|
1556
|
+
self.incremental(self.results)
|
|
1557
|
+
|
|
1558
|
+
try:
|
|
1559
|
+
next_command = self.commanditer.next()
|
|
1560
|
+
except StopIteration:
|
|
1561
|
+
log.msg(f"[{self.device}] CHANNEL: out of commands, closing connection...")
|
|
1562
|
+
self.loseConnection()
|
|
1563
|
+
return None
|
|
1564
|
+
|
|
1565
|
+
if next_command is None:
|
|
1566
|
+
self.results.append(None)
|
|
1567
|
+
self._send_next()
|
|
1568
|
+
else:
|
|
1569
|
+
log.msg(f"[{self.device}] Sending SSH command {next_command!r}")
|
|
1570
|
+
self.write(next_command + self.device.delimiter)
|
|
1571
|
+
|
|
1572
|
+
def loseConnection(self):
|
|
1573
|
+
"""
|
|
1574
|
+
Terminate the connection. Link this to the transport method of the same
|
|
1575
|
+
name.
|
|
1576
|
+
"""
|
|
1577
|
+
log.msg(f"[{self.device}] Forcefully closing transport connection")
|
|
1578
|
+
self.conn.transport.loseConnection()
|
|
1579
|
+
|
|
1580
|
+
def timeoutConnection(self):
|
|
1581
|
+
"""
|
|
1582
|
+
Do this when the connection times out.
|
|
1583
|
+
"""
|
|
1584
|
+
log.msg(f"[{self.device}] Timed out while sending commands")
|
|
1585
|
+
self.factory.err = exceptions.CommandTimeout("Timed out while sending commands")
|
|
1586
|
+
self.loseConnection()
|
|
1587
|
+
|
|
1588
|
+
def request_exit_status(self, data):
|
|
1589
|
+
status = struct.unpack(">L", data)[0]
|
|
1590
|
+
log.msg(f"[{self.device}] Exit status: {status}")
|
|
1591
|
+
|
|
1592
|
+
|
|
1593
|
+
class TriggerSSHGenericChannel(TriggerSSHChannelBase):
|
|
1594
|
+
"""
|
|
1595
|
+
An SSH channel using all of the Trigger defaults to interact with network
|
|
1596
|
+
devices that implement SSH without any tricks.
|
|
1597
|
+
|
|
1598
|
+
Currently A10, Cisco, Brocade, NetScreen can simply use this. Nice!
|
|
1599
|
+
|
|
1600
|
+
Before you create your own subclass, see if you can't use me as-is!
|
|
1601
|
+
"""
|
|
1602
|
+
|
|
1603
|
+
|
|
1604
|
+
class TriggerSSHAsyncPtyChannel(TriggerSSHChannelBase):
|
|
1605
|
+
"""
|
|
1606
|
+
An SSH channel that requests a non-interactive pty intended for async
|
|
1607
|
+
usage.
|
|
1608
|
+
|
|
1609
|
+
Some devices won't allow a shell without a pty, so we have to do a
|
|
1610
|
+
'pty-req'.
|
|
1611
|
+
|
|
1612
|
+
This is distinctly different from ~trigger.twister.TriggerSSHPtyChannel`
|
|
1613
|
+
which is intended for interactive end-user sessions.
|
|
1614
|
+
"""
|
|
1615
|
+
|
|
1616
|
+
def channelOpen(self, data):
|
|
1617
|
+
self._setup_channelOpen()
|
|
1618
|
+
|
|
1619
|
+
# Request a pty even tho we are not actually using one.
|
|
1620
|
+
pr = session.packRequest_pty_req(settings.TERM_TYPE, (80, 24, 0, 0), "")
|
|
1621
|
+
self.conn.sendRequest(self, "pty-req", pr)
|
|
1622
|
+
d = self.conn.sendRequest(self, "shell", "", wantReply=True)
|
|
1623
|
+
d.addCallback(self._gotResponse)
|
|
1624
|
+
d.addErrback(self._ebShellOpen)
|
|
1625
|
+
|
|
1626
|
+
|
|
1627
|
+
class TriggerSSHCommandChannel(TriggerSSHChannelBase):
|
|
1628
|
+
"""
|
|
1629
|
+
Run SSH commands on a system using 'exec'
|
|
1630
|
+
|
|
1631
|
+
This will multiplex channels over a single connection. Because of the
|
|
1632
|
+
nature of the multiplexing setup, the master list of commands is stored on
|
|
1633
|
+
the SSH connection, and the state of each command is stored within each
|
|
1634
|
+
individual channel which feeds its result back to the factory.
|
|
1635
|
+
"""
|
|
1636
|
+
|
|
1637
|
+
def __init__(self, command, *args, **kwargs):
|
|
1638
|
+
super().__init__(*args, **kwargs)
|
|
1639
|
+
self.command = command
|
|
1640
|
+
self.result = None
|
|
1641
|
+
self.data = ""
|
|
1642
|
+
|
|
1643
|
+
def channelOpen(self, data):
|
|
1644
|
+
"""Do this when the channel opens."""
|
|
1645
|
+
self._setup_channelOpen()
|
|
1646
|
+
log.msg(f"[{self.device}] Channel was opened")
|
|
1647
|
+
d = self.conn.sendRequest(self, "exec", common.NS(self.command), wantReply=True)
|
|
1648
|
+
d.addCallback(self._gotResponse)
|
|
1649
|
+
d.addErrback(self._ebShellOpen)
|
|
1650
|
+
|
|
1651
|
+
def _gotResponse(self, _):
|
|
1652
|
+
"""
|
|
1653
|
+
If the shell never establishes, this won't be called.
|
|
1654
|
+
"""
|
|
1655
|
+
log.msg(f"[{self.device}] CHANNEL {self.id}: Exec finished.")
|
|
1656
|
+
self.conn.sendEOF(self)
|
|
1657
|
+
|
|
1658
|
+
def _ebShellOpen(self, reason):
|
|
1659
|
+
log.msg(f"[{self.device}] CHANNEL {reason}: Channel request failed: {self.id}")
|
|
1660
|
+
|
|
1661
|
+
def dataReceived(self, bytes):
|
|
1662
|
+
self.data += bytes
|
|
1663
|
+
# log.msg('BYTES INFO: (left: %r, max: %r, bytes: %r, data: %r)' %
|
|
1664
|
+
# (self.remoteWindowLeft,
|
|
1665
|
+
# self.localMaxPacket,
|
|
1666
|
+
# len(bytes),
|
|
1667
|
+
# len(self.data)))
|
|
1668
|
+
log.msg(f"[{self.device}] BYTES RECV: {bytes!r}")
|
|
1669
|
+
|
|
1670
|
+
def eofReceived(self):
|
|
1671
|
+
log.msg(f"[{self.device}] CHANNEL {self.id}: EOF received.")
|
|
1672
|
+
result = self.data
|
|
1673
|
+
|
|
1674
|
+
# By default we're checking for IOS-like errors because most vendors
|
|
1675
|
+
# fall under this category.
|
|
1676
|
+
if has_ioslike_error(result) and not self.with_errors:
|
|
1677
|
+
log.msg(f"[{self.device}] Command failed: {result!r}")
|
|
1678
|
+
self.factory.err = exceptions.CommandFailure(result)
|
|
1679
|
+
|
|
1680
|
+
# Honor the command_interval and then send the next command
|
|
1681
|
+
else:
|
|
1682
|
+
self.result = result
|
|
1683
|
+
self.conn.transport.factory.results.append(self.result)
|
|
1684
|
+
self.send_next_command()
|
|
1685
|
+
|
|
1686
|
+
def send_next_command(self):
|
|
1687
|
+
"""Send the next command in the stack stored on the connection"""
|
|
1688
|
+
log.msg(f"[{self.device}] CHANNEL {self.id}: sending next command!")
|
|
1689
|
+
self.conn.send_command()
|
|
1690
|
+
|
|
1691
|
+
def closeReceived(self):
|
|
1692
|
+
log.msg(f"[{self.device}] CHANNEL {self.id}: Close received.")
|
|
1693
|
+
self.loseConnection()
|
|
1694
|
+
|
|
1695
|
+
def loseConnection(self):
|
|
1696
|
+
"""Default loseConnection"""
|
|
1697
|
+
log.msg(f"[{self.device}] LOSING CHANNEL CONNECTION")
|
|
1698
|
+
channel.SSHChannel.loseConnection(self)
|
|
1699
|
+
|
|
1700
|
+
def closed(self):
|
|
1701
|
+
log.msg(f"[{self.device}] Channel {self.id} closed")
|
|
1702
|
+
log.msg(f"[{self.device}] CONN CHANNELS: {len(self.conn.channels)}")
|
|
1703
|
+
|
|
1704
|
+
# If we're out of channels, shut it down!
|
|
1705
|
+
if len(self.conn.transport.factory.results) == len(self.conn.commands):
|
|
1706
|
+
log.msg(f"[{self.device}] RESULTS MATCHES COMMANDS SENT.")
|
|
1707
|
+
self.conn.transport.loseConnection()
|
|
1708
|
+
|
|
1709
|
+
def request_exit_status(self, data):
|
|
1710
|
+
exitStatus = int(struct.unpack(">L", data)[0])
|
|
1711
|
+
log.msg(f"[{self.device}] Exit status: {exitStatus}")
|
|
1712
|
+
|
|
1713
|
+
|
|
1714
|
+
class TriggerSSHJunoscriptChannel(TriggerSSHChannelBase):
|
|
1715
|
+
"""
|
|
1716
|
+
An SSH channel to execute Junoscript commands on a Juniper device running
|
|
1717
|
+
Junos.
|
|
1718
|
+
|
|
1719
|
+
This completely assumes that we are the only channel in the factory (a
|
|
1720
|
+
TriggerJunoscriptFactory) and walks all the way back up to the factory for
|
|
1721
|
+
its arguments.
|
|
1722
|
+
"""
|
|
1723
|
+
|
|
1724
|
+
def channelOpen(self, data):
|
|
1725
|
+
"""Do this when channel opens."""
|
|
1726
|
+
self._setup_channelOpen()
|
|
1727
|
+
self.conn.sendRequest(self, "exec", common.NS("junoscript"))
|
|
1728
|
+
_xml = '<?xml version="1.0" encoding="us-ascii"?>\n'
|
|
1729
|
+
# TODO (jathan): Make the release version dynamic at some point
|
|
1730
|
+
_xml += f'<junoscript version="1.0" hostname="{socket.getfqdn()}" release="7.6R2.9">\n'
|
|
1731
|
+
self.write(_xml)
|
|
1732
|
+
self.xmltb = IncrementalXMLTreeBuilder(self._endhandler)
|
|
1733
|
+
|
|
1734
|
+
self._send_next()
|
|
1735
|
+
|
|
1736
|
+
def dataReceived(self, data):
|
|
1737
|
+
"""Do this when we receive data."""
|
|
1738
|
+
log.msg(f"[{self.device}] BYTES: {data!r}")
|
|
1739
|
+
self.xmltb.feed(data)
|
|
1740
|
+
|
|
1741
|
+
def _send_next(self):
|
|
1742
|
+
"""Send the next command in the stack."""
|
|
1743
|
+
self.resetTimeout()
|
|
1744
|
+
|
|
1745
|
+
if self.incremental:
|
|
1746
|
+
self.incremental(self.results)
|
|
1747
|
+
|
|
1748
|
+
try:
|
|
1749
|
+
next_command = self.commanditer.next()
|
|
1750
|
+
log.msg(f"[{self.device}] COMMAND: next command {next_command}")
|
|
1751
|
+
|
|
1752
|
+
except StopIteration:
|
|
1753
|
+
log.msg(f"[{self.device}] CHANNEL: out of commands, closing connection...")
|
|
1754
|
+
self.loseConnection()
|
|
1755
|
+
return None
|
|
1756
|
+
|
|
1757
|
+
if next_command is None:
|
|
1758
|
+
self.results.append(None)
|
|
1759
|
+
self._send_next()
|
|
1760
|
+
else:
|
|
1761
|
+
rpc = Element("rpc")
|
|
1762
|
+
rpc.append(next_command)
|
|
1763
|
+
ElementTree(rpc).write(self)
|
|
1764
|
+
|
|
1765
|
+
def _endhandler(self, tag):
|
|
1766
|
+
"""Do this when the XML stream ends."""
|
|
1767
|
+
if tag.tag != "{http://xml.juniper.net/xnm/1.1/xnm}rpc-reply":
|
|
1768
|
+
return None # hopefully it's interior to an <rpc-reply>
|
|
1769
|
+
self.results.append(tag)
|
|
1770
|
+
|
|
1771
|
+
if has_junoscript_error(tag) and not self.with_errors:
|
|
1772
|
+
log.msg(f"[{self.device}] Command failed: {tag!r}")
|
|
1773
|
+
self.factory.err = exceptions.JunoscriptCommandFailure(tag)
|
|
1774
|
+
self.loseConnection()
|
|
1775
|
+
return None
|
|
1776
|
+
|
|
1777
|
+
# Honor the command_interval and then send the next command in the
|
|
1778
|
+
# stack
|
|
1779
|
+
else:
|
|
1780
|
+
if self.command_interval:
|
|
1781
|
+
log.msg(
|
|
1782
|
+
f"[{self.device}] Waiting {self.command_interval} seconds before sending next command"
|
|
1783
|
+
)
|
|
1784
|
+
reactor.callLater(self.command_interval, self._send_next)
|
|
1785
|
+
|
|
1786
|
+
|
|
1787
|
+
class TriggerSSHNetscalerChannel(TriggerSSHChannelBase):
|
|
1788
|
+
"""
|
|
1789
|
+
An SSH channel to interact with Citrix NetScaler hardware.
|
|
1790
|
+
|
|
1791
|
+
It's almost a generic SSH channel except that we must check for errors
|
|
1792
|
+
first, because a prompt is not returned when an error is received. This had
|
|
1793
|
+
to be accounted for in the ``dataReceived()`` method.
|
|
1794
|
+
"""
|
|
1795
|
+
|
|
1796
|
+
def dataReceived(self, bytes):
|
|
1797
|
+
"""Do this when we receive data."""
|
|
1798
|
+
self.data += bytes
|
|
1799
|
+
log.msg(f"[{self.device}] BYTES: {bytes!r}")
|
|
1800
|
+
# log.msg('BYTES: (left: %r, max: %r, bytes: %r, data: %r)' %
|
|
1801
|
+
# (self.remoteWindowLeft,
|
|
1802
|
+
# self.localMaxPacket,
|
|
1803
|
+
# len(bytes),
|
|
1804
|
+
# len(self.data)))
|
|
1805
|
+
|
|
1806
|
+
# We have to check for errors first, because a prompt is not returned
|
|
1807
|
+
# when an error is received like on other systems.
|
|
1808
|
+
if has_netscaler_error(self.data):
|
|
1809
|
+
err = self.data
|
|
1810
|
+
if not self.with_errors:
|
|
1811
|
+
log.msg(f"[{self.device}] Command failed: {err!r}")
|
|
1812
|
+
self.factory.err = exceptions.CommandFailure(err)
|
|
1813
|
+
self.loseConnection()
|
|
1814
|
+
return None
|
|
1815
|
+
else:
|
|
1816
|
+
self.results.append(err)
|
|
1817
|
+
self._send_next()
|
|
1818
|
+
|
|
1819
|
+
m = self.prompt.search(self.data)
|
|
1820
|
+
if not m:
|
|
1821
|
+
# log.msg('STATE: prompt match failure', debug=True)
|
|
1822
|
+
return None
|
|
1823
|
+
log.msg(f"[{self.device}] STATE: prompt {m.group()!r}")
|
|
1824
|
+
|
|
1825
|
+
result = self.data[: m.start()] # Strip ' Done\n' from results.
|
|
1826
|
+
|
|
1827
|
+
if self.initialized:
|
|
1828
|
+
self.results.append(result)
|
|
1829
|
+
|
|
1830
|
+
if self.command_interval:
|
|
1831
|
+
log.msg(
|
|
1832
|
+
f"[{self.device}] Waiting {self.command_interval} seconds before sending next command"
|
|
1833
|
+
)
|
|
1834
|
+
reactor.callLater(self.command_interval, self._send_next)
|
|
1835
|
+
|
|
1836
|
+
|
|
1837
|
+
PICA8_NO_MORE_COMMANDS = ["show"]
|
|
1838
|
+
|
|
1839
|
+
|
|
1840
|
+
class TriggerSSHPica8Channel(TriggerSSHAsyncPtyChannel):
|
|
1841
|
+
def _setup_commanditer(self, commands=None):
|
|
1842
|
+
"""
|
|
1843
|
+
Munge our list of commands and overload self.commanditer to append
|
|
1844
|
+
" | no-more" to any "show" commands.
|
|
1845
|
+
"""
|
|
1846
|
+
if commands is None:
|
|
1847
|
+
commands = self.factory.commands
|
|
1848
|
+
new_commands = []
|
|
1849
|
+
for command in commands:
|
|
1850
|
+
root = command.split(" ", 1)[0] # get the root command
|
|
1851
|
+
if root in PICA8_NO_MORE_COMMANDS:
|
|
1852
|
+
command += " | no-more"
|
|
1853
|
+
new_commands.append(command)
|
|
1854
|
+
self.commanditer = iter(new_commands)
|
|
1855
|
+
|
|
1856
|
+
def channelOpen(self, data):
|
|
1857
|
+
"""
|
|
1858
|
+
Override channel open, which is where commanditer is setup in the
|
|
1859
|
+
base class.
|
|
1860
|
+
"""
|
|
1861
|
+
super().channelOpen(data)
|
|
1862
|
+
self._setup_commanditer() # Replace self.commanditer with our version
|
|
1863
|
+
|
|
1864
|
+
|
|
1865
|
+
# ==================
|
|
1866
|
+
# XML Stuff (for Junoscript)
|
|
1867
|
+
# ==================
|
|
1868
|
+
|
|
1869
|
+
|
|
1870
|
+
class IncrementalXMLTreeBuilder(TreeBuilder):
|
|
1871
|
+
"""
|
|
1872
|
+
Version of TreeBuilder that runs a callback on each tag.
|
|
1873
|
+
|
|
1874
|
+
We need this because JunoScript treats the entire session as one XML
|
|
1875
|
+
document. IETF NETCONF fixes that.
|
|
1876
|
+
|
|
1877
|
+
Note: XMLTreeBuilder was renamed to TreeBuilder in Python 3.
|
|
1878
|
+
"""
|
|
1879
|
+
|
|
1880
|
+
def __init__(self, callback, *args, **kwargs):
|
|
1881
|
+
self._endhandler = callback
|
|
1882
|
+
TreeBuilder.__init__(self, *args, **kwargs)
|
|
1883
|
+
|
|
1884
|
+
def _end(self, tag):
|
|
1885
|
+
"""Do this when we're out of XML!"""
|
|
1886
|
+
return self._endhandler(TreeBuilder._end(self, tag))
|
|
1887
|
+
|
|
1888
|
+
|
|
1889
|
+
# ==================
|
|
1890
|
+
# Telnet Channels
|
|
1891
|
+
# ==================
|
|
1892
|
+
|
|
1893
|
+
|
|
1894
|
+
class TriggerTelnetClientFactory(TriggerClientFactory):
|
|
1895
|
+
"""
|
|
1896
|
+
Factory for a telnet connection.
|
|
1897
|
+
"""
|
|
1898
|
+
|
|
1899
|
+
def __init__(
|
|
1900
|
+
self,
|
|
1901
|
+
deferred,
|
|
1902
|
+
action,
|
|
1903
|
+
creds=None,
|
|
1904
|
+
loginpw=None,
|
|
1905
|
+
enablepw=None,
|
|
1906
|
+
init_commands=None,
|
|
1907
|
+
device=None,
|
|
1908
|
+
):
|
|
1909
|
+
self.protocol = TriggerTelnet
|
|
1910
|
+
self.action = action
|
|
1911
|
+
self.loginpw = loginpw
|
|
1912
|
+
self.enablepw = os.getenv("TRIGGER_ENABLEPW", enablepw)
|
|
1913
|
+
self.device = device
|
|
1914
|
+
self.action.factory = self
|
|
1915
|
+
TriggerClientFactory.__init__(self, deferred, creds, init_commands)
|
|
1916
|
+
|
|
1917
|
+
|
|
1918
|
+
class TriggerTelnet(telnet.Telnet, telnet.ProtocolTransportMixin, TimeoutMixin):
|
|
1919
|
+
"""
|
|
1920
|
+
Telnet-based session login state machine. Primarily used by IOS-like type
|
|
1921
|
+
devices.
|
|
1922
|
+
"""
|
|
1923
|
+
|
|
1924
|
+
def __init__(self, timeout=settings.TELNET_TIMEOUT):
|
|
1925
|
+
self.protocol = telnet.TelnetProtocol()
|
|
1926
|
+
self.waiting_for = [
|
|
1927
|
+
("Username: ", self.state_username), # Most
|
|
1928
|
+
("Please Enter Login Name : ", self.state_username), # OLD Fndry
|
|
1929
|
+
("User Name:", self.state_username), # Dell
|
|
1930
|
+
("login: ", self.state_username), # EOS, JunOs
|
|
1931
|
+
("Password: ", self.state_login_pw),
|
|
1932
|
+
]
|
|
1933
|
+
self.data = ""
|
|
1934
|
+
self.applicationDataReceived = self.login_state_machine
|
|
1935
|
+
self.timeout = timeout
|
|
1936
|
+
self.setTimeout(self.timeout)
|
|
1937
|
+
telnet.Telnet.__init__(self)
|
|
1938
|
+
|
|
1939
|
+
def enableRemote(self, option):
|
|
1940
|
+
"""
|
|
1941
|
+
Allow telnet clients to enable options if for some reason they aren't
|
|
1942
|
+
enabled already (e.g. ECHO). (Ref: http://bit.ly/wkFZFg) For some
|
|
1943
|
+
reason Arista Networks hardware is the only vendor that needs this
|
|
1944
|
+
method right now.
|
|
1945
|
+
"""
|
|
1946
|
+
# log.msg('[%s] enableRemote option: %r' % (self.host, option))
|
|
1947
|
+
log.msg(f"enableRemote option: {option!r}")
|
|
1948
|
+
return True
|
|
1949
|
+
|
|
1950
|
+
def login_state_machine(self, bytes):
|
|
1951
|
+
"""Track user login state."""
|
|
1952
|
+
self.host = self.transport.connector.host
|
|
1953
|
+
log.msg(f"[{self.host}] CONNECTOR HOST: {self.transport.connector.host}")
|
|
1954
|
+
self.data += bytes
|
|
1955
|
+
log.msg(f"[{self.host}] STATE: got data {self.data!r}")
|
|
1956
|
+
for text, next_state in self.waiting_for:
|
|
1957
|
+
log.msg(f"[{self.host}] STATE: possible matches {text!r}")
|
|
1958
|
+
if self.data.endswith(text):
|
|
1959
|
+
log.msg(f"[{self.host}] Entering state {next_state.__name__!r}")
|
|
1960
|
+
self.resetTimeout()
|
|
1961
|
+
next_state()
|
|
1962
|
+
self.data = ""
|
|
1963
|
+
break
|
|
1964
|
+
|
|
1965
|
+
def state_username(self):
|
|
1966
|
+
"""After we've gotten username, check for password prompt."""
|
|
1967
|
+
self.write(self.factory.creds.username + "\n")
|
|
1968
|
+
self.waiting_for = [
|
|
1969
|
+
("Password: ", self.state_password),
|
|
1970
|
+
("Password:", self.state_password), # Dell
|
|
1971
|
+
]
|
|
1972
|
+
|
|
1973
|
+
def state_password(self):
|
|
1974
|
+
"""After we got password prompt, check for enabled prompt."""
|
|
1975
|
+
self.write(self.factory.creds.password + "\n")
|
|
1976
|
+
self.waiting_for = [
|
|
1977
|
+
("#", self.state_logged_in),
|
|
1978
|
+
(">", self.state_enable),
|
|
1979
|
+
("> ", self.state_logged_in), # Juniper
|
|
1980
|
+
("\n% ", self.state_percent_error),
|
|
1981
|
+
("# ", self.state_logged_in), # Dell
|
|
1982
|
+
("\nUsername: ", self.state_raise_error), # Cisco
|
|
1983
|
+
("\nlogin: ", self.state_raise_error), # Arista, Juniper
|
|
1984
|
+
]
|
|
1985
|
+
|
|
1986
|
+
def state_logged_in(self):
|
|
1987
|
+
"""
|
|
1988
|
+
Once we're logged in, exit state machine and pass control to the
|
|
1989
|
+
action.
|
|
1990
|
+
"""
|
|
1991
|
+
self.setTimeout(None)
|
|
1992
|
+
data = self.data.lstrip("\n")
|
|
1993
|
+
log.msg(f"[{self.host}] state_logged_in, DATA: {data!r}")
|
|
1994
|
+
del self.waiting_for, self.data
|
|
1995
|
+
|
|
1996
|
+
# Run init_commands
|
|
1997
|
+
self.factory._init_commands(protocol=self) # We are the protocol
|
|
1998
|
+
|
|
1999
|
+
# Control passed here :)
|
|
2000
|
+
action = self.factory.action
|
|
2001
|
+
action.transport = self
|
|
2002
|
+
self.applicationDataReceived = action.dataReceived
|
|
2003
|
+
self.connectionLost = action.connectionLost
|
|
2004
|
+
action.write = self.write
|
|
2005
|
+
action.loseConnection = self.loseConnection
|
|
2006
|
+
action.connectionMade()
|
|
2007
|
+
action.dataReceived(data)
|
|
2008
|
+
|
|
2009
|
+
def state_enable(self):
|
|
2010
|
+
"""
|
|
2011
|
+
Special Foundry breakage because they don't do auto-enable from
|
|
2012
|
+
TACACS by default. Use 'aaa authentication login privilege-mode'.
|
|
2013
|
+
Also, why no space after the Password: prompt here?
|
|
2014
|
+
"""
|
|
2015
|
+
log.msg(f"[{self.host}] ENABLE: Sending command: enable")
|
|
2016
|
+
self.write("enable\n")
|
|
2017
|
+
self.waiting_for = [
|
|
2018
|
+
("Password: ", self.state_enable_pw), # Foundry
|
|
2019
|
+
("Password:", self.state_enable_pw), # Dell
|
|
2020
|
+
]
|
|
2021
|
+
|
|
2022
|
+
def state_login_pw(self):
|
|
2023
|
+
"""Pass the login password from the factory or NetDevices"""
|
|
2024
|
+
if self.factory.loginpw:
|
|
2025
|
+
pw = self.factory.loginpw
|
|
2026
|
+
else:
|
|
2027
|
+
from trigger.netdevices import NetDevices
|
|
2028
|
+
|
|
2029
|
+
pw = NetDevices().find(self.host).loginPW
|
|
2030
|
+
|
|
2031
|
+
# Workaround to avoid TypeError when concatenating 'NoneType' and
|
|
2032
|
+
# 'str'. This *should* result in a LoginFailure.
|
|
2033
|
+
if pw is None:
|
|
2034
|
+
pw = ""
|
|
2035
|
+
|
|
2036
|
+
# log.msg('Sending password %s' % pw)
|
|
2037
|
+
self.write(pw + "\n")
|
|
2038
|
+
self.waiting_for = [
|
|
2039
|
+
(">", self.state_enable),
|
|
2040
|
+
("#", self.state_logged_in),
|
|
2041
|
+
("\n% ", self.state_percent_error),
|
|
2042
|
+
("incorrect password.", self.state_raise_error),
|
|
2043
|
+
]
|
|
2044
|
+
|
|
2045
|
+
def state_enable_pw(self):
|
|
2046
|
+
"""Pass the enable password from the factory or NetDevices"""
|
|
2047
|
+
if self.factory.enablepw:
|
|
2048
|
+
pw = self.factory.enablepw
|
|
2049
|
+
else:
|
|
2050
|
+
from trigger.netdevices import NetDevices
|
|
2051
|
+
|
|
2052
|
+
pw = NetDevices().find(self.host).enablePW
|
|
2053
|
+
# log.msg('Sending password %s' % pw)
|
|
2054
|
+
self.write(pw + "\n")
|
|
2055
|
+
self.waiting_for = [
|
|
2056
|
+
("#", self.state_logged_in),
|
|
2057
|
+
("\n% ", self.state_percent_error),
|
|
2058
|
+
("incorrect password.", self.state_raise_error),
|
|
2059
|
+
]
|
|
2060
|
+
|
|
2061
|
+
def state_percent_error(self):
|
|
2062
|
+
"""
|
|
2063
|
+
Found a % error message. Don't return immediately because we
|
|
2064
|
+
don't have the error text yet.
|
|
2065
|
+
"""
|
|
2066
|
+
self.waiting_for = [("\n", self.state_raise_error)]
|
|
2067
|
+
|
|
2068
|
+
def state_raise_error(self):
|
|
2069
|
+
"""Do this when we get a login failure."""
|
|
2070
|
+
self.waiting_for = []
|
|
2071
|
+
log.msg(f"Failed logging into {self.transport.connector.host}")
|
|
2072
|
+
self.factory.err = exceptions.LoginFailure(f"{self.data.rstrip()!r}")
|
|
2073
|
+
self.loseConnection()
|
|
2074
|
+
|
|
2075
|
+
def timeoutConnection(self):
|
|
2076
|
+
"""Do this when we timeout logging in."""
|
|
2077
|
+
log.msg(f"[{self.transport.connector.host}] Timed out while logging in")
|
|
2078
|
+
self.factory.err = exceptions.LoginTimeout("Timed out while logging in")
|
|
2079
|
+
self.loseConnection()
|
|
2080
|
+
|
|
2081
|
+
|
|
2082
|
+
class IoslikeSendExpect(protocol.Protocol, TimeoutMixin):
|
|
2083
|
+
"""
|
|
2084
|
+
Action for use with TriggerTelnet as a state machine.
|
|
2085
|
+
|
|
2086
|
+
Take a list of commands, and send them to the device until we run out or
|
|
2087
|
+
one errors. Wait for a prompt after each.
|
|
2088
|
+
"""
|
|
2089
|
+
|
|
2090
|
+
def __init__(
|
|
2091
|
+
self,
|
|
2092
|
+
device,
|
|
2093
|
+
commands,
|
|
2094
|
+
incremental=None,
|
|
2095
|
+
with_errors=False,
|
|
2096
|
+
timeout=None,
|
|
2097
|
+
command_interval=0,
|
|
2098
|
+
):
|
|
2099
|
+
self.device = device
|
|
2100
|
+
self._commands = commands
|
|
2101
|
+
self.commanditer = iter(commands)
|
|
2102
|
+
self.incremental = incremental
|
|
2103
|
+
self.with_errors = with_errors
|
|
2104
|
+
self.timeout = timeout
|
|
2105
|
+
self.command_interval = command_interval
|
|
2106
|
+
self.prompt = re.compile(settings.IOSLIKE_PROMPT_PAT)
|
|
2107
|
+
self.startup_commands = copy.copy(self.device.startup_commands)
|
|
2108
|
+
log.msg(f"[{self.device}] My initialize commands: {self.startup_commands!r}")
|
|
2109
|
+
self.initialized = False
|
|
2110
|
+
|
|
2111
|
+
def connectionMade(self):
|
|
2112
|
+
"""Do this when we connect."""
|
|
2113
|
+
self.setTimeout(self.timeout)
|
|
2114
|
+
self.results = self.factory.results = []
|
|
2115
|
+
self.data = ""
|
|
2116
|
+
log.msg(f"[{self.device}] connectionMade, data: {self.data!r}")
|
|
2117
|
+
|
|
2118
|
+
# Don't call _send_next, since we expect to see a prompt, which
|
|
2119
|
+
# will kick off initialization.
|
|
2120
|
+
|
|
2121
|
+
def dataReceived(self, bytes):
|
|
2122
|
+
"""Do this when we get data."""
|
|
2123
|
+
log.msg(f"[{self.device}] BYTES: {bytes!r}")
|
|
2124
|
+
self.data += bytes
|
|
2125
|
+
|
|
2126
|
+
# See if the prompt matches, and if it doesn't, see if it is waiting
|
|
2127
|
+
# for more input (like a [y/n]) prompt), and continue, otherwise return
|
|
2128
|
+
# None
|
|
2129
|
+
m = self.prompt.search(self.data)
|
|
2130
|
+
if not m:
|
|
2131
|
+
# If the prompt confirms set the index to the matched bytes,
|
|
2132
|
+
if is_awaiting_confirmation(self.data):
|
|
2133
|
+
log.msg(f"[{self.device}] Got confirmation prompt: {self.data!r}")
|
|
2134
|
+
prompt_idx = self.data.find(bytes)
|
|
2135
|
+
else:
|
|
2136
|
+
return None
|
|
2137
|
+
else:
|
|
2138
|
+
# Or just use the matched regex object...
|
|
2139
|
+
prompt_idx = m.start()
|
|
2140
|
+
|
|
2141
|
+
result = self.data[:prompt_idx]
|
|
2142
|
+
# Trim off the echoed-back command. This should *not* be necessary
|
|
2143
|
+
# since the telnet session is in WONT ECHO. This is confirmed with
|
|
2144
|
+
# a packet trace, and running self.transport.dont(ECHO) from
|
|
2145
|
+
# connectionMade() returns an AlreadyDisabled error. What's up?
|
|
2146
|
+
log.msg(f"[{self.device}] result BEFORE: {result!r}")
|
|
2147
|
+
result = result[result.find("\n") + 1 :]
|
|
2148
|
+
log.msg(f"[{self.device}] result AFTER: {result!r}")
|
|
2149
|
+
|
|
2150
|
+
if self.initialized:
|
|
2151
|
+
self.results.append(result)
|
|
2152
|
+
|
|
2153
|
+
if has_ioslike_error(result) and not self.with_errors:
|
|
2154
|
+
log.msg(f"[{self.device}] Command failed: {result!r}")
|
|
2155
|
+
self.factory.err = exceptions.IoslikeCommandFailure(result)
|
|
2156
|
+
self.loseConnection()
|
|
2157
|
+
else:
|
|
2158
|
+
if self.command_interval:
|
|
2159
|
+
log.msg(
|
|
2160
|
+
f"[{self.device}] Waiting {self.command_interval} seconds before sending next command"
|
|
2161
|
+
)
|
|
2162
|
+
reactor.callLater(self.command_interval, self._send_next)
|
|
2163
|
+
|
|
2164
|
+
def _send_next(self):
|
|
2165
|
+
"""Send the next command in the stack."""
|
|
2166
|
+
self.data = ""
|
|
2167
|
+
self.resetTimeout()
|
|
2168
|
+
|
|
2169
|
+
if not self.initialized:
|
|
2170
|
+
log.msg(f"[{self.device}] Not initialized, sending startup commands")
|
|
2171
|
+
if self.startup_commands:
|
|
2172
|
+
next_init = self.startup_commands.pop(0)
|
|
2173
|
+
log.msg(f"[{self.device}] Sending initialize command: {next_init!r}")
|
|
2174
|
+
self.write(next_init.strip() + self.device.delimiter)
|
|
2175
|
+
return None
|
|
2176
|
+
else:
|
|
2177
|
+
log.msg(
|
|
2178
|
+
f"[{self.device}] Successfully initialized for command execution"
|
|
2179
|
+
)
|
|
2180
|
+
self.initialized = True
|
|
2181
|
+
|
|
2182
|
+
if self.incremental:
|
|
2183
|
+
self.incremental(self.results)
|
|
2184
|
+
|
|
2185
|
+
try:
|
|
2186
|
+
next_command = self.commanditer.next()
|
|
2187
|
+
except StopIteration:
|
|
2188
|
+
log.msg(f"[{self.device}] No more commands to send, disconnecting...")
|
|
2189
|
+
self.loseConnection()
|
|
2190
|
+
return None
|
|
2191
|
+
|
|
2192
|
+
if next_command is None:
|
|
2193
|
+
self.results.append(None)
|
|
2194
|
+
self._send_next()
|
|
2195
|
+
else:
|
|
2196
|
+
log.msg(f"[{self.device}] Sending command {next_command!r}")
|
|
2197
|
+
self.write(next_command + self.device.delimiter)
|
|
2198
|
+
|
|
2199
|
+
def timeoutConnection(self):
|
|
2200
|
+
"""Do this when we timeout."""
|
|
2201
|
+
log.msg(f"[{self.device}] Timed out while sending commands")
|
|
2202
|
+
self.factory.err = exceptions.CommandTimeout("Timed out while sending commands")
|
|
2203
|
+
self.loseConnection()
|