"Fossies" - the Fresh Open Source Software Archive 
Member "salt-3002.2/salt/client/ssh/shell.py" (18 Nov 2020, 14732 Bytes) of package /linux/misc/salt-3002.2.tar.gz:
As a special service "Fossies" has tried to format the requested source page into HTML format using (guessed) Python source code syntax highlighting (style:
standard) with prefixed line numbers.
Alternatively you can here
view or
download the uninterpreted source code file.
For more information about "shell.py" see the
Fossies "Dox" file reference documentation and the latest
Fossies "Diffs" side-by-side code changes report:
3002.1_vs_3002.2.
1 """
2 Manage transport commands via ssh
3 """
4
5 import logging
6 import os
7 import re
8 import shlex
9 import subprocess
10 import sys
11 import time
12
13 import salt.defaults.exitcodes
14 import salt.utils.json
15 import salt.utils.nb_popen
16 import salt.utils.vt
17
18 log = logging.getLogger(__name__)
19
20 SSH_PASSWORD_PROMPT_RE = re.compile(r"(?:.*)[Pp]assword(?: for .*)?:", re.M)
21 KEY_VALID_RE = re.compile(r".*\(yes\/no\).*")
22 SSH_PRIVATE_KEY_PASSWORD_PROMPT_RE = re.compile(r"Enter passphrase for key", re.M)
23
24 # Keep these in sync with ./__init__.py
25 RSTR = "_edbc7885e4f9aac9b83b35999b68d015148caf467b78fa39c05f669c0ff89878"
26 RSTR_RE = re.compile(r"(?:^|\r?\n)" + RSTR + r"(?:\r?\n|$)")
27
28
29 def gen_key(path):
30 """
31 Generate a key for use with salt-ssh
32 """
33 cmd = ["ssh-keygen", "-P", "", "-f", path, "-t", "rsa", "-q"]
34 dirname = os.path.dirname(path)
35 if dirname and not os.path.isdir(dirname):
36 os.makedirs(os.path.dirname(path))
37 subprocess.call(cmd)
38
39
40 def gen_shell(opts, **kwargs):
41 """
42 Return the correct shell interface for the target system
43 """
44 if kwargs["winrm"]:
45 try:
46 import saltwinshell
47
48 shell = saltwinshell.Shell(opts, **kwargs)
49 except ImportError:
50 log.error("The saltwinshell library is not available")
51 sys.exit(salt.defaults.exitcodes.EX_GENERIC)
52 else:
53 shell = Shell(opts, **kwargs)
54 return shell
55
56
57 class Shell:
58 """
59 Create a shell connection object to encapsulate ssh executions
60 """
61
62 def __init__(
63 self,
64 opts,
65 host,
66 user=None,
67 port=None,
68 passwd=None,
69 priv=None,
70 priv_passwd=None,
71 timeout=None,
72 sudo=False,
73 tty=False,
74 mods=None,
75 identities_only=False,
76 sudo_user=None,
77 remote_port_forwards=None,
78 winrm=False,
79 ssh_options=None,
80 ):
81 self.opts = opts
82 # ssh <ipv6>, but scp [<ipv6]:/path
83 self.host = host.strip("[]")
84 self.user = user
85 self.port = port
86 self.passwd = str(passwd) if passwd else passwd
87 self.priv = priv
88 self.priv_passwd = priv_passwd
89 self.timeout = timeout
90 self.sudo = sudo
91 self.tty = tty
92 self.mods = mods
93 self.identities_only = identities_only
94 self.remote_port_forwards = remote_port_forwards
95 self.ssh_options = "" if ssh_options is None else ssh_options
96
97 def get_error(self, errstr):
98 """
99 Parse out an error and return a targeted error string
100 """
101 for line in errstr.split("\n"):
102 if line.startswith("ssh:"):
103 return line
104 if line.startswith("Pseudo-terminal"):
105 continue
106 if "to the list of known hosts." in line:
107 continue
108 return line
109 return errstr
110
111 def _key_opts(self):
112 """
113 Return options for the ssh command base for Salt to call
114 """
115 options = [
116 "KbdInteractiveAuthentication=no",
117 ]
118 if self.passwd:
119 options.append("PasswordAuthentication=yes")
120 else:
121 options.append("PasswordAuthentication=no")
122 if self.opts.get("_ssh_version", (0,)) > (4, 9):
123 options.append("GSSAPIAuthentication=no")
124 options.append("ConnectTimeout={}".format(self.timeout))
125 if self.opts.get("ignore_host_keys"):
126 options.append("StrictHostKeyChecking=no")
127 if self.opts.get("no_host_keys"):
128 options.extend(["StrictHostKeyChecking=no", "UserKnownHostsFile=/dev/null"])
129 known_hosts = self.opts.get("known_hosts_file")
130 if known_hosts and os.path.isfile(known_hosts):
131 options.append("UserKnownHostsFile={}".format(known_hosts))
132 if self.port:
133 options.append("Port={}".format(self.port))
134 if self.priv and self.priv != "agent-forwarding":
135 options.append("IdentityFile={}".format(self.priv))
136 if self.user:
137 options.append("User={}".format(self.user))
138 if self.identities_only:
139 options.append("IdentitiesOnly=yes")
140
141 ret = []
142 for option in options:
143 ret.append("-o {} ".format(option))
144 return "".join(ret)
145
146 def _passwd_opts(self):
147 """
148 Return options to pass to ssh
149 """
150 # TODO ControlMaster does not work without ControlPath
151 # user could take advantage of it if they set ControlPath in their
152 # ssh config. Also, ControlPersist not widely available.
153 options = [
154 "ControlMaster=auto",
155 "StrictHostKeyChecking=no",
156 ]
157 if self.opts["_ssh_version"] > (4, 9):
158 options.append("GSSAPIAuthentication=no")
159 options.append("ConnectTimeout={}".format(self.timeout))
160 if self.opts.get("ignore_host_keys"):
161 options.append("StrictHostKeyChecking=no")
162 if self.opts.get("no_host_keys"):
163 options.extend(["StrictHostKeyChecking=no", "UserKnownHostsFile=/dev/null"])
164
165 if self.passwd:
166 options.extend(["PasswordAuthentication=yes", "PubkeyAuthentication=yes"])
167 else:
168 options.extend(
169 [
170 "PasswordAuthentication=no",
171 "PubkeyAuthentication=yes",
172 "KbdInteractiveAuthentication=no",
173 "ChallengeResponseAuthentication=no",
174 "BatchMode=yes",
175 ]
176 )
177 if self.port:
178 options.append("Port={}".format(self.port))
179 if self.user:
180 options.append("User={}".format(self.user))
181 if self.identities_only:
182 options.append("IdentitiesOnly=yes")
183
184 ret = []
185 for option in options:
186 ret.append("-o {} ".format(option))
187 return "".join(ret)
188
189 def _ssh_opts(self):
190 return " ".join(["-o {}".format(opt) for opt in self.ssh_options])
191
192 def _copy_id_str_old(self):
193 """
194 Return the string to execute ssh-copy-id
195 """
196 if self.passwd:
197 # Using single quotes prevents shell expansion and
198 # passwords containing '$'
199 return "{} {} '{} -p {} {} {}@{}'".format(
200 "ssh-copy-id",
201 "-i {}.pub".format(self.priv),
202 self._passwd_opts(),
203 self.port,
204 self._ssh_opts(),
205 self.user,
206 self.host,
207 )
208 return None
209
210 def _copy_id_str_new(self):
211 """
212 Since newer ssh-copy-id commands ingest option differently we need to
213 have two commands
214 """
215 if self.passwd:
216 # Using single quotes prevents shell expansion and
217 # passwords containing '$'
218 return "{} {} {} -p {} {} {}@{}".format(
219 "ssh-copy-id",
220 "-i {}.pub".format(self.priv),
221 self._passwd_opts(),
222 self.port,
223 self._ssh_opts(),
224 self.user,
225 self.host,
226 )
227 return None
228
229 def copy_id(self):
230 """
231 Execute ssh-copy-id to plant the id file on the target
232 """
233 stdout, stderr, retcode = self._run_cmd(self._copy_id_str_old())
234 if salt.defaults.exitcodes.EX_OK != retcode and "Usage" in stderr:
235 stdout, stderr, retcode = self._run_cmd(self._copy_id_str_new())
236 return stdout, stderr, retcode
237
238 def _cmd_str(self, cmd, ssh="ssh"):
239 """
240 Return the cmd string to execute
241 """
242
243 # TODO: if tty, then our SSH_SHIM cannot be supplied from STDIN Will
244 # need to deliver the SHIM to the remote host and execute it there
245
246 command = [ssh]
247 if ssh != "scp":
248 command.append(self.host)
249 if self.tty and ssh == "ssh":
250 command.append("-t -t")
251 if self.passwd or self.priv:
252 command.append(self.priv and self._key_opts() or self._passwd_opts())
253 if ssh != "scp" and self.remote_port_forwards:
254 command.append(
255 " ".join(
256 [
257 "-R {}".format(item)
258 for item in self.remote_port_forwards.split(",")
259 ]
260 )
261 )
262 if self.ssh_options:
263 command.append(self._ssh_opts())
264
265 command.append(cmd)
266
267 return " ".join(command)
268
269 def _run_nb_cmd(self, cmd):
270 """
271 cmd iterator
272 """
273 try:
274 proc = salt.utils.nb_popen.NonBlockingPopen(
275 self._split_cmd(cmd), stderr=subprocess.PIPE, stdout=subprocess.PIPE,
276 )
277 while True:
278 time.sleep(0.1)
279 out = proc.recv()
280 err = proc.recv_err()
281 rcode = proc.returncode
282 if out is None and err is None:
283 break
284 if err:
285 err = self.get_error(err)
286 yield out, err, rcode
287 except Exception: # pylint: disable=broad-except
288 yield ("", "Unknown Error", None)
289
290 def exec_nb_cmd(self, cmd):
291 """
292 Yield None until cmd finished
293 """
294 r_out = []
295 r_err = []
296 rcode = None
297 cmd = self._cmd_str(cmd)
298
299 logmsg = "Executing non-blocking command: {}".format(cmd)
300 if self.passwd:
301 logmsg = logmsg.replace(self.passwd, ("*" * 6))
302 log.debug(logmsg)
303
304 for out, err, rcode in self._run_nb_cmd(cmd):
305 if out is not None:
306 r_out.append(out)
307 if err is not None:
308 r_err.append(err)
309 yield None, None, None
310 yield "".join(r_out), "".join(r_err), rcode
311
312 def exec_cmd(self, cmd):
313 """
314 Execute a remote command
315 """
316 cmd = self._cmd_str(cmd)
317
318 logmsg = "Executing command: {}".format(cmd)
319 if self.passwd:
320 logmsg = logmsg.replace(self.passwd, ("*" * 6))
321 if 'decode("base64")' in logmsg or "base64.b64decode(" in logmsg:
322 log.debug("Executed SHIM command. Command logged to TRACE")
323 log.trace(logmsg)
324 else:
325 log.debug(logmsg)
326
327 ret = self._run_cmd(cmd)
328 return ret
329
330 def send(self, local, remote, makedirs=False):
331 """
332 scp a file or files to a remote system
333 """
334 if makedirs:
335 self.exec_cmd("mkdir -p {}".format(os.path.dirname(remote)))
336
337 # scp needs [<ipv6}
338 host = self.host
339 if ":" in host:
340 host = "[{}]".format(host)
341
342 cmd = "{} {}:{}".format(local, host, remote)
343 cmd = self._cmd_str(cmd, ssh="scp")
344
345 logmsg = "Executing command: {}".format(cmd)
346 if self.passwd:
347 logmsg = logmsg.replace(self.passwd, ("*" * 6))
348 log.debug(logmsg)
349
350 return self._run_cmd(cmd)
351
352 def _split_cmd(self, cmd):
353 """
354 Split a command string so that it is suitable to pass to Popen without
355 shell=True. This prevents shell injection attacks in the options passed
356 to ssh or some other command.
357 """
358 try:
359 ssh_part, cmd_part = cmd.split("/bin/sh")
360 except ValueError:
361 cmd_lst = shlex.split(cmd)
362 else:
363 cmd_lst = shlex.split(ssh_part)
364 cmd_lst.append("/bin/sh {}".format(cmd_part))
365 return cmd_lst
366
367 def _run_cmd(self, cmd, key_accept=False, passwd_retries=3):
368 """
369 Execute a shell command via VT. This is blocking and assumes that ssh
370 is being run
371 """
372 if not cmd:
373 return "", "No command or passphrase", 245
374
375 term = salt.utils.vt.Terminal(
376 self._split_cmd(cmd),
377 log_stdout=True,
378 log_stdout_level="trace",
379 log_stderr=True,
380 log_stderr_level="trace",
381 stream_stdout=False,
382 stream_stderr=False,
383 )
384 sent_passwd = 0
385 send_password = True
386 ret_stdout = ""
387 ret_stderr = ""
388 old_stdout = ""
389
390 try:
391 while term.has_unread_data:
392 stdout, stderr = term.recv()
393 if stdout:
394 ret_stdout += stdout
395 buff = old_stdout + stdout
396 else:
397 buff = stdout
398 if stderr:
399 ret_stderr += stderr
400 if buff and RSTR_RE.search(buff):
401 # We're getting results back, don't try to send passwords
402 send_password = False
403 if buff and SSH_PRIVATE_KEY_PASSWORD_PROMPT_RE.search(buff):
404 if not self.priv_passwd:
405 return "", "Private key file need passphrase", 254
406 term.sendline(self.priv_passwd)
407 continue
408 if buff and SSH_PASSWORD_PROMPT_RE.search(buff) and send_password:
409 if not self.passwd:
410 return (
411 "",
412 "Permission denied, no authentication information",
413 254,
414 )
415 if sent_passwd < passwd_retries:
416 term.sendline(self.passwd)
417 sent_passwd += 1
418 continue
419 else:
420 # asking for a password, and we can't seem to send it
421 return "", "Password authentication failed", 254
422 elif buff and KEY_VALID_RE.search(buff):
423 if key_accept:
424 term.sendline("yes")
425 continue
426 else:
427 term.sendline("no")
428 ret_stdout = (
429 "The host key needs to be accepted, to "
430 "auto accept run salt-ssh with the -i "
431 "flag:\n{}"
432 ).format(stdout)
433 return ret_stdout, "", 254
434 elif buff and buff.endswith("_||ext_mods||_"):
435 mods_raw = (
436 salt.utils.json.dumps(self.mods, separators=(",", ":"))
437 + "|_E|0|"
438 )
439 term.sendline(mods_raw)
440 if stdout:
441 old_stdout = stdout
442 time.sleep(0.01)
443 return ret_stdout, ret_stderr, term.exitstatus
444 finally:
445 term.close(terminate=True, kill=True)