-
Notifications
You must be signed in to change notification settings - Fork 71
Expand file tree
/
Copy pathssh.py
More file actions
396 lines (338 loc) · 14.6 KB
/
ssh.py
File metadata and controls
396 lines (338 loc) · 14.6 KB
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
# (c) Copyright 2014-2015 Hewlett Packard Enterprise Development LP
# All Rights Reserved.
#
# 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.
"""
HPE 3PAR SSH Client
.. module: ssh
:Author: Walter A. Boring IV
:Description: This is the SSH Client that is used to make calls to
the 3PAR where an existing REST API doesn't exist.
"""
import logging
import os
import paramiko
from random import randint
import re
from eventlet import greenthread
from hpe3parclient import exceptions
# Python 3+ override
try:
basestring
python3 = False
except NameError:
basestring = str
python3 = True
# Commands that require Tpd::rtpd prefix
tpd_commands = [
'createfstore',
'getfstore',
'getfsquota',
'getfshare'
]
class HPE3PARSSHClient(object):
"""This class is used to execute SSH commands on a 3PAR."""
log_debug = False
_logger = logging.getLogger(__name__)
_logger.setLevel(logging.INFO)
_log_handler = None
def __init__(self, ip, login, password,
port=22, conn_timeout=None, privatekey=None,
**kwargs):
self.san_ip = ip
self.san_ssh_port = port
self.ssh_conn_timeout = conn_timeout
self.san_login = login
self.san_password = password
self.san_privatekey = privatekey
self._create_ssh(**kwargs)
def _create_ssh(self, **kwargs):
try:
ssh = paramiko.SSHClient()
known_hosts_file = kwargs.get('known_hosts_file', None)
if known_hosts_file is None:
ssh.load_system_host_keys()
else:
# Make sure we can open the file for appending first.
# This is needed to create the file when we run CI tests with
# no existing key file.
open(known_hosts_file, 'a').close()
ssh.load_host_keys(known_hosts_file)
missing_key_policy = kwargs.get('missing_key_policy', None)
if missing_key_policy is None:
missing_key_policy = paramiko.AutoAddPolicy()
elif isinstance(missing_key_policy, basestring):
# To make it configurable, allow string to be mapped to object.
if missing_key_policy == paramiko.AutoAddPolicy().__class__.\
__name__:
missing_key_policy = paramiko.AutoAddPolicy()
elif missing_key_policy == paramiko.RejectPolicy().__class__.\
__name__:
missing_key_policy = paramiko.RejectPolicy()
elif missing_key_policy == paramiko.WarningPolicy().__class__.\
__name__:
missing_key_policy = paramiko.WarningPolicy()
else:
raise exceptions.SSHException(
"Invalid missing_key_policy: %s" % missing_key_policy
)
ssh.set_missing_host_key_policy(missing_key_policy)
self.ssh = ssh
except Exception as e:
msg = "Error connecting via ssh: %s" % e
self._logger.error(msg)
raise paramiko.SSHException(msg)
def _connect(self, ssh):
if self.san_password:
ssh.connect(self.san_ip,
port=self.san_ssh_port,
username=self.san_login,
password=self.san_password,
timeout=self.ssh_conn_timeout)
elif self.san_privatekey:
pkfile = os.path.expanduser(self.san_privatekey)
privatekey = paramiko.RSAKey.from_private_key_file(pkfile)
ssh.connect(self.san_ip,
port=self.san_ssh_port,
username=self.san_login,
pkey=privatekey,
timeout=self.ssh_conn_timeout)
else:
msg = "Specify a password or private_key"
raise exceptions.SSHException(msg)
def open(self):
"""Opens a new SSH connection if the transport layer is missing.
This can be called if an active SSH connection is open already.
"""
# Create a new SSH connection if the transport layer is missing.
if self.ssh:
transport_active = False
if self.ssh.get_transport():
transport_active = self.ssh.get_transport().is_active()
if not transport_active:
try:
self._connect(self.ssh)
except Exception as e:
msg = "Error connecting via ssh: %s" % e
self._logger.error(msg)
raise paramiko.SSHException(msg)
def close(self):
if self.ssh:
self.ssh.close()
@classmethod
def set_debug_flag(cls, flag):
"""This turns on/off log output for ssh commands.
By default logs are disabled, even for error messages, enabling debug
mode will enable ssh logs at debug level.
:param flag: Whether we want to have logs or not
:type flag: bool
"""
flag = bool(flag) # In case we don't receive a bool instance
if flag != cls.log_debug:
if flag:
if cls._log_handler is None:
cls._log_handler = logging.StreamHandler()
cls._logger.addHandler(cls._log_handler)
cls._logger.setLevel(logging.DEBUG)
else:
cls._logger.setLevel(logging.INFO)
cls._logger.removeHandler(cls._log_handler)
cls.log_debug = flag
@staticmethod
def sanitize_cert(output_list):
if isinstance(output_list, list):
output = ''.join(output_list)
else:
output = output_list
try:
begin_cert_str = '-BEGIN CERTIFICATE-'
begin_cert_pos = output.index(begin_cert_str)
pre = ''.join((output[:begin_cert_pos], begin_cert_str,
'sanitized'))
try:
end_cert_str = '-END CERTIFICATE-'
end_cert_pos = output.index(end_cert_str)
return pre if begin_cert_pos >= end_cert_pos else ''.join(
(pre, output[end_cert_pos:]))
except ValueError:
return pre
except ValueError:
return output
@staticmethod
def raise_stripper_error(reason, output):
msg = "Multi-line stripper failed: %s" % reason
HPE3PARSSHClient._logger.error(msg)
HPE3PARSSHClient._logger.debug("Output: %s" %
HPE3PARSSHClient.sanitize_cert(output))
raise exceptions.SSHException(msg)
@staticmethod
def strip_input_from_output(cmd, output):
"""The input commands are echoed in the output. Strip that.
The legacy way of doing this expected a fixed number of before and
after lines. With Unity many commands are being broken into multiple
lines, so the stripper needs to adjust.
This new stripper attempts to recognize the input commands and prompt
in the output so that it knows what it is stripping (or else it
raises an exception).
"""
# Keep output lines after the 'exit'.
# 'exit' is the last of the stdin.
for i, line in enumerate(output):
if line == 'exit':
output = output[i + 1:]
break
else:
reason = "Did not find 'exit' in output."
HPE3PARSSHClient.raise_stripper_error(reason, output)
if not output:
reason = "Did not find any output after 'exit'."
HPE3PARSSHClient.raise_stripper_error(reason, output)
# The next line is prompt plus setclienv command.
# Use this to get the prompt string.
prompt_pct = output[0].find('% setclienv csvtable 1')
if prompt_pct < 0:
reason = "Did not find '% setclienv csvtable 1' in output."
HPE3PARSSHClient.raise_stripper_error(reason, output)
prompt = output[0][0:prompt_pct + 1]
del output[0]
# Next find the prompt plus the command.
# It might be broken into multiple lines, so loop and
# append until we find the whole prompt plus command.
command_string = ' '.join(cmd)
if re.match('|'.join(tpd_commands), command_string):
escp_command_string = command_string.replace('"', '\\"')
command_string = "Tpd::rtpd " + '"' + escp_command_string + '"'
seek = ' '.join((prompt, command_string))
found = ''
for i, line in enumerate(output):
found = ''.join((found, line.rstrip('\r\n')))
if found == seek:
# Found the whole thing. Use the rest as output now.
output = output[i + 1:]
break
else:
HPE3PARSSHClient._logger.debug("Command: %s" % command_string)
reason = "Did not find match for command in output"
HPE3PARSSHClient.raise_stripper_error(reason, output)
# Always strip the last 2
return output[:len(output) - 2]
def run(self, cmd, multi_line_stripper=False):
"""Runs a CLI command over SSH, without doing any result parsing."""
self._logger.debug("SSH CMD = %s " % cmd)
(stdout, stderr) = self._run_ssh(cmd, False)
# we have to strip out the input and exit lines
if python3:
tmp = stdout.decode().split("\r\n")
else:
tmp = stdout.split("\r\n")
# default is old stripper -- to avoid breaking things, for now
if multi_line_stripper:
out = self.strip_input_from_output(cmd, tmp)
self._logger.debug("OUT = %s" % self.sanitize_cert(out))
else:
out = tmp[5:len(tmp) - 2]
self._logger.debug("OUT = %s" % out)
return out
def _ssh_execute(self, cmd, check_exit_code=True):
"""We have to do this in order to get CSV output from the CLI command.
We first have to issue a command to tell the CLI that we want the
output to be formatted in CSV, then we issue the real command.
"""
if re.match('|'.join(tpd_commands), cmd):
cmd = 'Tpd::rtpd "' + cmd.replace('"', '\\"') + '"'
self._logger.debug('Running cmd (SSH): %s', cmd)
channel = self.ssh.invoke_shell()
stdin_stream = channel.makefile('wb')
stdout_stream = channel.makefile('rb')
stderr_stream = channel.makefile('rb')
stdin_stream.write('''setclienv csvtable 1
%s
exit
''' % cmd)
# stdin.write('process_input would go here')
# stdin.flush()
# NOTE(justinsb): This seems suspicious...
# ...other SSH clients have buffering issues with this approach
stdout = stdout_stream.read()
stderr = stderr_stream.read()
stdin_stream.close()
stdout_stream.close()
stderr_stream.close()
exit_status = channel.recv_exit_status()
# exit_status == -1 if no exit code was returned
if exit_status != -1:
self._logger.debug('Result was %s' % exit_status)
if check_exit_code and exit_status != 0:
msg = "command %s failed" % cmd
self._logger.error(msg)
raise exceptions.ProcessExecutionError(exit_code=exit_status,
stdout=stdout,
stderr=stderr,
cmd=cmd)
channel.close()
return (stdout, stderr)
def _run_ssh(self, cmd_list, check_exit=True, attempts=2):
self.check_ssh_injection(cmd_list)
command = ' '. join(cmd_list)
try:
total_attempts = attempts
while attempts > 0:
attempts -= 1
try:
return self._ssh_execute(command,
check_exit_code=check_exit)
except Exception as e:
self._logger.error(e)
if attempts > 0:
greenthread.sleep(randint(20, 500) / 100.0)
if not self.ssh.get_transport().is_alive():
self._create_ssh()
msg = ("SSH Command failed after '%(total_attempts)r' "
"attempts : '%(command)s'" %
{'total_attempts': total_attempts, 'command': command})
self._logger.error(msg)
raise exceptions.SSHException(message=msg)
except Exception:
self._logger.error("Error running ssh command: %s" % command)
raise
def check_ssh_injection(self, cmd_list):
ssh_injection_pattern = ['`', '$', '|', '||', ';', '&', '&&',
'>', '>>', '<']
# Check whether injection attacks exist
for arg in cmd_list:
arg = arg.strip()
# Check for matching quotes on the ends
is_quoted = re.match('^(?P<quote>[\'"])(?P<quoted>.*)(?P=quote)$',
arg)
if is_quoted:
# Check for unescaped quotes within the quoted argument
quoted = is_quoted.group('quoted')
if quoted:
if (re.match('[\'"]', quoted) or
re.search('[^\\\\][\'"]', quoted)):
raise exceptions.SSHInjectionThreat(
command=str(cmd_list))
else:
# We only allow spaces within quoted arguments, and that
# is the only special character allowed within quotes
if len(arg.split()) > 1:
raise exceptions.SSHInjectionThreat(command=str(cmd_list))
# Second, check whether danger character in command. So the shell
# special operator must be a single argument.
for c in ssh_injection_pattern:
if arg == c:
continue
result = arg.find(c)
if not result == -1:
if result == 0 or not arg[result - 1] == '\\':
raise exceptions.SSHInjectionThreat(command=cmd_list)