multiSSH3 4.81__py3-none-any.whl → 4.89__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.

Potentially problematic release.


This version of multiSSH3 might be problematic. Click here for more details.

@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: multiSSH3
3
- Version: 4.81
3
+ Version: 4.89
4
4
  Summary: Run commands on multiple hosts via SSH
5
5
  Home-page: https://github.com/yufei-pan/multiSSH3
6
6
  Author: Yufei Pan
@@ -20,12 +20,12 @@ Can be used in bash scripts for automation actions.
20
20
  Also able to be imported and / or use with Flexec SSH Backend to perform cluster automation actions.
21
21
 
22
22
  Install via
23
- ```
23
+ ```bash
24
24
  pip install multiSSH3
25
25
  ```
26
26
 
27
27
  multiSSH3 will be available as
28
- ```
28
+ ```bash
29
29
  mssh
30
30
  mssh3
31
31
  multissh
@@ -37,7 +37,7 @@ multissh will read a config file located at ```/etc/multiSSH3.config.json```
37
37
 
38
38
  To store / generate a config file with the current command line options, you can use
39
39
 
40
- ```
40
+ ```bash
41
41
  mssh --generate_default_config_file
42
42
  ```
43
43
 
@@ -49,42 +49,52 @@ If you want to store password, it will be a plain text password in this config f
49
49
 
50
50
  This option can also be used to store cli options into the config files. For example.
51
51
 
52
- ```
52
+ ```bash
53
53
  mssh --ipmi_interface_ip_prefix 192 --generate_default_config_file
54
54
  ```
55
55
  will store
56
- ```
56
+ ```json
57
57
  "DEFAULT_IPMI_INTERFACE_IP_PREFIX": "192"
58
58
  ```
59
59
  into the json file.
60
60
 
61
61
  By defualt reads bash env variables for hostname aliases. Also able to read
62
- ```
62
+ ```bash
63
63
  DEFAULT_ENV_FILE = '/etc/profile.d/hosts.sh'
64
64
  ```
65
65
  as hostname aliases.
66
66
 
67
67
  For example:
68
- ```
68
+ ```bash
69
69
  export all='192.168.1-2.1-64'
70
70
  mssh all 'echo hi'
71
71
  ```
72
72
 
73
+ Note: you probably want to set presistent ssh connections to speed up each connection events.
74
+ An example .ssh/config:
75
+ ```bash
76
+ Host *
77
+ StrictHostKeyChecking no
78
+ ControlMaster auto
79
+ ControlPath /run/ssh_sockets_%r@%h-%p
80
+ ControlPersist 3600
81
+ ```
82
+
73
83
  It is also able to recognize ip blocks / number blocks / hex blocks / character blocks directly.
74
84
 
75
85
  For example:
76
- ```
86
+ ```bash
77
87
  mssh testrig[1-10] lsblk
78
88
  mssh ww[a-c],10.100.0.* 'cat /etc/fstab' 'sed -i "/lustre/d' /etc/fstab' 'cat /etc/fstab'
79
89
  ```
80
90
 
81
91
  It also supports interactive inputs. ( and able to async boardcast to all supplied hosts )
82
- ```
92
+ ```bash
83
93
  mssh www bash
84
94
  ```
85
95
 
86
96
  By default, it will try to fit everything inside your window.
87
- ```
97
+ ```bash
88
98
  DEFAULT_CURSES_MINIMUM_CHAR_LEN = 40
89
99
  DEFAULT_CURSES_MINIMUM_LINE_LEN = 1
90
100
  ```
@@ -93,7 +103,7 @@ While leaving minimum 40 characters / 1 line for each host display by default. Y
93
103
 
94
104
  Use ```mssh --help``` for more info.
95
105
 
96
- ```
106
+ ```bash
97
107
  usage: mssh [-h] [-u USERNAME] [-ea EXTRAARGS] [-p PASSWORD] [-11] [-f FILE] [--file_sync] [--scp] [-t TIMEOUT] [-r REPEAT] [-i INTERVAL] [--ipmi]
98
108
  [-pre INTERFACE_IP_PREFIX] [-q] [-ww WINDOW_WIDTH] [-wh WINDOW_HEIGHT] [-sw] [-eo] [-no] [--no_env] [--env_file ENV_FILE] [-m MAXCONNECTIONS] [-j]
99
109
  [--success_hosts] [-g] [-nw] [-su] [-sh SKIPHOSTS] [-V]
@@ -164,8 +174,6 @@ Following document is generated courtesy of Mr.ChatGPT-o1 Preview:
164
174
  - [multissh](#multissh)
165
175
  - [Table of Contents](#table-of-contents)
166
176
  - [Features](#features)
167
- - [Installation](#installation)
168
- - [Usage](#usage)
169
177
  - [Basic Syntax](#basic-syntax)
170
178
  - [Command-Line Options](#command-line-options)
171
179
  - [Examples](#examples)
@@ -194,36 +202,10 @@ Following document is generated courtesy of Mr.ChatGPT-o1 Preview:
194
202
  - **Interactive Mode**: Run interactive commands with curses-based UI for monitoring.
195
203
  - **Quiet Mode**: Suppress output for cleaner automation scripts.
196
204
 
197
- ## Installation
198
-
199
- 1. **Clone the Repository**
200
-
201
- ```bash
202
- git clone https://github.com/yourusername/multissh.git
203
- ```
204
-
205
- 2. **Navigate to the Directory**
206
-
207
- ```bash
208
- cd multissh
209
- ```
210
-
211
- 3. **Make the Script Executable**
212
-
213
- ```bash
214
- chmod +x multissh.py
215
- ```
216
-
217
- 4. **Install Dependencies**
218
-
219
- Ensure you have Python 3 and the required modules installed. You may need to install `curses` and `ipaddress` modules if they are not already available.
220
-
221
- ## Usage
222
-
223
205
  ### Basic Syntax
224
206
 
225
207
  ```bash
226
- ./multissh.py [options] <hosts> <commands>
208
+ mssh [options] <hosts> <commands>
227
209
  ```
228
210
 
229
211
  - `<hosts>`: Comma-separated list of target hosts. Supports ranges and wildcards.
@@ -267,7 +249,7 @@ Following document is generated courtesy of Mr.ChatGPT-o1 Preview:
267
249
  ### Running a Command on Multiple Hosts
268
250
 
269
251
  ```bash
270
- ./multissh.py "host1,host2,host3" "uptime"
252
+ mssh "host1,host2,host3" "uptime"
271
253
  ```
272
254
 
273
255
  This command runs `uptime` on `host1`, `host2`, and `host3`.
@@ -275,7 +257,7 @@ This command runs `uptime` on `host1`, `host2`, and `host3`.
275
257
  ### Copying Files to Multiple Hosts
276
258
 
277
259
  ```bash
278
- ./multissh.py -f "/path/to/local/file.txt" "host1,host2,host3" "/remote/path/"
260
+ mssh -f "/path/to/local/file.txt" "host1,host2,host3" "/remote/path/"
279
261
  ```
280
262
 
281
263
  This command copies `file.txt` to `/remote/path/` on the specified hosts.
@@ -283,7 +265,7 @@ This command copies `file.txt` to `/remote/path/` on the specified hosts.
283
265
  ### Using Hostname Ranges
284
266
 
285
267
  ```bash
286
- ./multissh.py "host[01-05]" "hostname"
268
+ mssh "host[01-05]" "hostname"
287
269
  ```
288
270
 
289
271
  This expands to `host01`, `host02`, `host03`, `host04`, `host05` and runs `hostname` on each.
@@ -291,7 +273,7 @@ This expands to `host01`, `host02`, `host03`, `host04`, `host05` and runs `hostn
291
273
  ### Using IPMI
292
274
 
293
275
  ```bash
294
- ./multissh.py --ipmi "192.168.1.[100-105]" "chassis power status"
276
+ mssh --ipmi "192.168.1.[100-105]" "chassis power status"
295
277
  ```
296
278
 
297
279
  Runs `ipmitool chassis power status` on the specified IPMI interfaces.
@@ -299,7 +281,7 @@ Runs `ipmitool chassis power status` on the specified IPMI interfaces.
299
281
  ### Using Password Authentication
300
282
 
301
283
  ```bash
302
- ./multissh.py -p "yourpassword" "host1,host2" "whoami"
284
+ mssh -p "yourpassword" "host1,host2" "whoami"
303
285
  ```
304
286
 
305
287
  Uses `sshpass` to provide the password for SSH authentication.
@@ -307,7 +289,7 @@ Uses `sshpass` to provide the password for SSH authentication.
307
289
  ### Skipping Unreachable Hosts
308
290
 
309
291
  ```bash
310
- ./multissh.py -su "host1,host2,host3" "date"
292
+ mssh -su "host1,host2,host3" "date"
311
293
  ```
312
294
 
313
295
  Skips hosts that are unreachable during execution.
@@ -315,7 +297,7 @@ Skips hosts that are unreachable during execution.
315
297
  ### JSON Output
316
298
 
317
299
  ```bash
318
- ./multissh.py -j "host1,host2" "uname -a"
300
+ mssh -j "host1,host2" "uname -a"
319
301
  ```
320
302
 
321
303
  Outputs the results in JSON format, suitable for parsing.
@@ -323,7 +305,7 @@ Outputs the results in JSON format, suitable for parsing.
323
305
  ### Quiet Mode
324
306
 
325
307
  ```bash
326
- ./multissh.py -q "host1,host2" "ls /nonexistent"
308
+ mssh -q "host1,host2" "ls /nonexistent"
327
309
  ```
328
310
 
329
311
  Suppresses all output, useful for scripts where you only care about exit codes.
@@ -335,8 +317,6 @@ Suppresses all output, useful for scripts where you only care about exit codes.
335
317
  - Use `--no_env` to prevent loading any environment variables from files.
336
318
 
337
319
  ## Notes
338
-
339
- - **SSH Configuration**: The script modifies `~/.ssh/config` to disable `StrictHostKeyChecking`. Ensure this is acceptable in your environment.
340
320
  - **Dependencies**: Requires Python 3, `sshpass` (if using password authentication), and standard Unix utilities like `ssh`, `scp`, and `rsync`.
341
321
  - **Signal Handling**: Supports graceful termination with `Ctrl+C`.
342
322
 
@@ -0,0 +1,7 @@
1
+ multiSSH3.py,sha256=g_U5Kk6pDABART5Tb4yoViPZ-5fiARpb_8Irvs7f2QA,86990
2
+ multiSSH3-4.89.dist-info/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
3
+ multiSSH3-4.89.dist-info/METADATA,sha256=J9K4EyDzcP7Pvj4p779YAkYz7vbDGfQoxz9hmkWRra8,16043
4
+ multiSSH3-4.89.dist-info/WHEEL,sha256=GV9aMThwP_4oNCtvEC2ec3qUYutgWeAzklro_0m4WJQ,91
5
+ multiSSH3-4.89.dist-info/entry_points.txt,sha256=xi2rWWNfmHx6gS8Mmx0rZL2KZz6XWBYP3DWBpWAnnZ0,143
6
+ multiSSH3-4.89.dist-info/top_level.txt,sha256=tUwttxlnpLkZorSsroIprNo41lYSxjd2ASuL8-EJIJw,10
7
+ multiSSH3-4.89.dist-info/RECORD,,
multiSSH3.py CHANGED
@@ -15,7 +15,9 @@ import io
15
15
  import signal
16
16
  import functools
17
17
  import glob
18
- #import fnmatch
18
+ import shutil
19
+ import getpass
20
+
19
21
  try:
20
22
  # Check if functiools.cache is available
21
23
  cache_decorator = functools.cache
@@ -27,7 +29,7 @@ except AttributeError:
27
29
  # If neither is available, use a dummy decorator
28
30
  def cache_decorator(func):
29
31
  return func
30
- version = '4.81'
32
+ version = '4.89'
31
33
  VERSION = version
32
34
 
33
35
  CONFIG_FILE = '/etc/multiSSH3.config.json'
@@ -81,19 +83,27 @@ __build_in_default_config = {
81
83
  'DEFAULT_GREPPABLE_MODE': False,
82
84
  'DEFAULT_SKIP_UNREACHABLE': False,
83
85
  'DEFAULT_SKIP_HOSTS': '',
86
+ 'SSH_STRICT_HOST_KEY_CHECKING': False,
84
87
  'ERROR_MESSAGES_TO_IGNORE': [
85
88
  'Pseudo-terminal will not be allocated because stdin is not a terminal',
86
89
  'Connection to .* closed',
87
90
  'Warning: Permanently added',
88
91
  'mux_client_request_session',
89
92
  'disabling multiplexing',
93
+ 'Killed by signal',
94
+ 'Connection reset by peer',
90
95
  ],
91
96
  '_DEFAULT_CALLED': True,
92
97
  '_DEFAULT_RETURN_UNFINISHED': False,
93
98
  '_DEFAULT_UPDATE_UNREACHABLE_HOSTS': True,
94
99
  '_DEFAULT_NO_START': False,
95
100
  '_etc_hosts': {},
96
- '_sshpassAvailable': False,
101
+ '_sshpassPath': None,
102
+ '_sshPath': None,
103
+ '_scpPath': None,
104
+ '_ipmitoolPath': None,
105
+ '_rsyncPath': None,
106
+ '_bashPath': None,
97
107
  '__ERROR_MESSAGES_TO_IGNORE_REGEX':None,
98
108
  }
99
109
 
@@ -131,6 +141,8 @@ DEFAULT_GREPPABLE_MODE = __configs_from_file.get('DEFAULT_GREPPABLE_MODE', __bui
131
141
  DEFAULT_SKIP_UNREACHABLE = __configs_from_file.get('DEFAULT_SKIP_UNREACHABLE', __build_in_default_config['DEFAULT_SKIP_UNREACHABLE'])
132
142
  DEFAULT_SKIP_HOSTS = __configs_from_file.get('DEFAULT_SKIP_HOSTS', __build_in_default_config['DEFAULT_SKIP_HOSTS'])
133
143
 
144
+ SSH_STRICT_HOST_KEY_CHECKING = __configs_from_file.get('SSH_STRICT_HOST_KEY_CHECKING', __build_in_default_config['SSH_STRICT_HOST_KEY_CHECKING'])
145
+
134
146
  ERROR_MESSAGES_TO_IGNORE = __configs_from_file.get('ERROR_MESSAGES_TO_IGNORE', __build_in_default_config['ERROR_MESSAGES_TO_IGNORE'])
135
147
 
136
148
  _DEFAULT_CALLED = __configs_from_file.get('_DEFAULT_CALLED', __build_in_default_config['_DEFAULT_CALLED'])
@@ -139,7 +151,8 @@ _DEFAULT_UPDATE_UNREACHABLE_HOSTS = __configs_from_file.get('_DEFAULT_UPDATE_UNR
139
151
  _DEFAULT_NO_START = __configs_from_file.get('_DEFAULT_NO_START', __build_in_default_config['_DEFAULT_NO_START'])
140
152
 
141
153
  # form the regex from the list
142
- if '__ERROR_MESSAGES_TO_IGNORE_REGEX' in __configs_from_file:
154
+ __ERROR_MESSAGES_TO_IGNORE_REGEX = __configs_from_file.get('__ERROR_MESSAGES_TO_IGNORE_REGEX', __build_in_default_config['__ERROR_MESSAGES_TO_IGNORE_REGEX'])
155
+ if __ERROR_MESSAGES_TO_IGNORE_REGEX:
143
156
  print('Using __ERROR_MESSAGES_TO_IGNORE_REGEX from config file, ignoring ERROR_MESSAGES_TO_IGNORE')
144
157
  __ERROR_MESSAGES_TO_IGNORE_REGEX = re.compile(__configs_from_file['__ERROR_MESSAGES_TO_IGNORE_REGEX'])
145
158
  else:
@@ -152,7 +165,7 @@ __global_suppress_printout = True
152
165
  __mainReturnCode = 0
153
166
  __failedHosts = set()
154
167
  class Host:
155
- def __init__(self, name, command, files = None,ipmi = False,interface_ip_prefix = None,scp=False,extraargs=None):
168
+ def __init__(self, name, command, files = None,ipmi = False,interface_ip_prefix = None,scp=False,extraargs=None,gatherMode=False):
156
169
  self.name = name # the name of the host (hostname or IP address)
157
170
  self.command = command # the command to run on the host
158
171
  self.returncode = None # the return code of the command
@@ -164,19 +177,24 @@ class Host:
164
177
  self.ipmi = ipmi # whether to use ipmi to connect to the host
165
178
  self.interface_ip_prefix = interface_ip_prefix # the prefix of the ip address of the interface to be used to connect to the host
166
179
  self.scp = scp # whether to use scp to copy files to the host
180
+ self.gatherMode = gatherMode # whether the host is in gather mode
167
181
  self.extraargs = extraargs # extra arguments to be passed to ssh
168
182
  self.resolvedName = None # the resolved IP address of the host
169
183
  def __iter__(self):
170
184
  return zip(['name', 'command', 'returncode', 'stdout', 'stderr'], [self.name, self.command, self.returncode, self.stdout, self.stderr])
171
185
  def __repr__(self):
172
186
  # return the complete data structure
173
- return f"Host(name={self.name}, command={self.command}, returncode={self.returncode}, stdout={self.stdout}, stderr={self.stderr}, output={self.output}, printedLines={self.printedLines}, files={self.files}, ipmi={self.ipmi}, interface_ip_prefix={self.interface_ip_prefix}, scp={self.scp}, extraargs={self.extraargs}, resolvedName={self.resolvedName})"
187
+ return f"Host(name={self.name}, command={self.command}, returncode={self.returncode}, stdout={self.stdout}, stderr={self.stderr}, output={self.output}, printedLines={self.printedLines}, files={self.files}, ipmi={self.ipmi}, interface_ip_prefix={self.interface_ip_prefix}, scp={self.scp}, gatherMode={self.gatherMode}, extraargs={self.extraargs}, resolvedName={self.resolvedName})"
174
188
  def __str__(self):
175
189
  return f"Host(name={self.name}, command={self.command}, returncode={self.returncode}, stdout={self.stdout}, stderr={self.stderr})"
176
190
 
177
191
  __wildCharacters = ['*','?','x']
178
192
 
179
- __gloablUnavailableHosts = set()
193
+ _no_env = DEFAULT_NO_ENV
194
+
195
+ _env_file = DEFAULT_ENV_FILE
196
+
197
+ __globalUnavailableHosts = set()
180
198
 
181
199
  __ipmiiInterfaceIPPrefix = DEFAULT_IPMI_INTERFACE_IP_PREFIX
182
200
 
@@ -186,15 +204,26 @@ _emo = False
186
204
 
187
205
  _etc_hosts = __configs_from_file.get('_etc_hosts', __build_in_default_config['_etc_hosts'])
188
206
 
189
- _env_file = DEFAULT_ENV_FILE
190
207
 
191
208
  # check if command sshpass is available
192
- _sshpassAvailable = __configs_from_file.get('_sshpassAvailable', __build_in_default_config['_sshpassAvailable'])
193
- try:
194
- subprocess.run(['which', 'sshpass'], stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True)
195
- _sshpassAvailable = True
196
- except:
197
- pass
209
+ _binPaths = {}
210
+ def check_path(program_name):
211
+ global __configs_from_file
212
+ global __build_in_default_config
213
+ global _binPaths
214
+ config_key = f'_{program_name}Path'
215
+ program_path = (
216
+ __configs_from_file.get(config_key) or
217
+ __build_in_default_config.get(config_key) or
218
+ shutil.which(program_name)
219
+ )
220
+ if program_path:
221
+ _binPaths[program_name] = program_path
222
+ return True
223
+ return False
224
+
225
+ [check_path(program) for program in ['sshpass', 'ssh', 'scp', 'ipmitool','rsync','bash']]
226
+
198
227
 
199
228
 
200
229
  @cache_decorator
@@ -242,37 +271,6 @@ def expandIPv4Address(hosts):
242
271
  expandedHosts.extend(expandedHost)
243
272
  return expandedHosts
244
273
 
245
- @cache_decorator
246
- def readEnvFromFile(environemnt_file = ''):
247
- '''
248
- Read the environment variables from env_file
249
- Returns:
250
- dict: A dictionary of environment variables
251
- '''
252
- global env
253
- try:
254
- if env:
255
- return env
256
- except:
257
- env = {}
258
- global _env_file
259
- if environemnt_file:
260
- envf = environemnt_file
261
- else:
262
- envf = _env_file if _env_file else DEFAULT_ENV_FILE
263
- if os.path.exists(envf):
264
- with open(envf,'r') as f:
265
- for line in f:
266
- if line.startswith('#') or not line.strip():
267
- continue
268
- key, value = line.replace('export ', '', 1).strip().split('=', 1)
269
- key = key.strip().strip('"').strip("'")
270
- value = value.strip().strip('"').strip("'")
271
- # avoid infinite recursion
272
- if key != value:
273
- env[key] = value.strip('"').strip("'")
274
- return env
275
-
276
274
  @cache_decorator
277
275
  def getIP(hostname,local=False):
278
276
  '''
@@ -313,9 +311,40 @@ def getIP(hostname,local=False):
313
311
  return socket.gethostbyname(hostname)
314
312
  except:
315
313
  return None
314
+
315
+ @cache_decorator
316
+ def readEnvFromFile(environemnt_file = ''):
317
+ '''
318
+ Read the environment variables from env_file
319
+ Returns:
320
+ dict: A dictionary of environment variables
321
+ '''
322
+ global env
323
+ try:
324
+ if env:
325
+ return env
326
+ except:
327
+ env = {}
328
+ global _env_file
329
+ if environemnt_file:
330
+ envf = environemnt_file
331
+ else:
332
+ envf = _env_file if _env_file else DEFAULT_ENV_FILE
333
+ if os.path.exists(envf):
334
+ with open(envf,'r') as f:
335
+ for line in f:
336
+ if line.startswith('#') or not line.strip():
337
+ continue
338
+ key, value = line.replace('export ', '', 1).strip().split('=', 1)
339
+ key = key.strip().strip('"').strip("'")
340
+ value = value.strip().strip('"').strip("'")
341
+ # avoid infinite recursion
342
+ if key != value:
343
+ env[key] = value.strip('"').strip("'")
344
+ return env
316
345
 
317
346
  @cache_decorator
318
- def expand_hostname(text,validate=True,no_env=False):
347
+ def expand_hostname(text,validate=True):
319
348
  '''
320
349
  Expand the hostname range in the text.
321
350
  Will search the string for a range ( [] encloused and non enclosed number ranges).
@@ -336,12 +365,12 @@ def expand_hostname(text,validate=True,no_env=False):
336
365
  hostname = expandinghosts.pop()
337
366
  match = re.search(r'\[(.*?-.*?)\]', hostname)
338
367
  if not match:
339
- expandedhosts.update(validate_expand_hostname(hostname,no_env=no_env) if validate else [hostname])
368
+ expandedhosts.update(validate_expand_hostname(hostname) if validate else [hostname])
340
369
  continue
341
370
  try:
342
371
  range_start, range_end = match.group(1).split('-')
343
372
  except ValueError:
344
- expandedhosts.update(validate_expand_hostname(hostname,no_env=no_env) if validate else [hostname])
373
+ expandedhosts.update(validate_expand_hostname(hostname) if validate else [hostname])
345
374
  continue
346
375
  range_start = range_start.strip()
347
376
  range_end = range_end.strip()
@@ -353,7 +382,7 @@ def expand_hostname(text,validate=True,no_env=False):
353
382
  elif range_start.isalpha() and range_start.isupper():
354
383
  range_end = 'Z'
355
384
  else:
356
- expandedhosts.update(validate_expand_hostname(hostname,no_env=no_env) if validate else [hostname])
385
+ expandedhosts.update(validate_expand_hostname(hostname) if validate else [hostname])
357
386
  continue
358
387
  if not range_start:
359
388
  if range_end.isdigit():
@@ -363,7 +392,7 @@ def expand_hostname(text,validate=True,no_env=False):
363
392
  elif range_end.isalpha() and range_end.isupper():
364
393
  range_start = 'A'
365
394
  else:
366
- expandedhosts.update(validate_expand_hostname(hostname,no_env=no_env) if validate else [hostname])
395
+ expandedhosts.update(validate_expand_hostname(hostname) if validate else [hostname])
367
396
  continue
368
397
  if range_start.isdigit() and range_end.isdigit():
369
398
  padding_length = min(len(range_start), len(range_end))
@@ -373,14 +402,14 @@ def expand_hostname(text,validate=True,no_env=False):
373
402
  if '[' in hostname:
374
403
  expandinghosts.append(hostname.replace(match.group(0), formatted_i, 1))
375
404
  else:
376
- expandedhosts.update(validate_expand_hostname(hostname.replace(match.group(0), formatted_i, 1),no_env=no_env) if validate else [hostname])
405
+ expandedhosts.update(validate_expand_hostname(hostname.replace(match.group(0), formatted_i, 1)) if validate else [hostname])
377
406
  else:
378
407
  if all(c in string.hexdigits for c in range_start + range_end):
379
408
  for i in range(int(range_start, 16), int(range_end, 16)+1):
380
409
  if '[' in hostname:
381
410
  expandinghosts.append(hostname.replace(match.group(0), format(i, 'x'), 1))
382
411
  else:
383
- expandedhosts.update(validate_expand_hostname(hostname.replace(match.group(0), format(i, 'x'), 1),no_env=no_env) if validate else [hostname])
412
+ expandedhosts.update(validate_expand_hostname(hostname.replace(match.group(0), format(i, 'x'), 1)) if validate else [hostname])
384
413
  else:
385
414
  try:
386
415
  start_index = alphanumeric.index(range_start)
@@ -389,13 +418,13 @@ def expand_hostname(text,validate=True,no_env=False):
389
418
  if '[' in hostname:
390
419
  expandinghosts.append(hostname.replace(match.group(0), alphanumeric[i], 1))
391
420
  else:
392
- expandedhosts.update(validate_expand_hostname(hostname.replace(match.group(0), alphanumeric[i], 1),no_env=no_env) if validate else [hostname])
421
+ expandedhosts.update(validate_expand_hostname(hostname.replace(match.group(0), alphanumeric[i], 1)) if validate else [hostname])
393
422
  except ValueError:
394
- expandedhosts.update(validate_expand_hostname(hostname,no_env=no_env) if validate else [hostname])
423
+ expandedhosts.update(validate_expand_hostname(hostname) if validate else [hostname])
395
424
  return expandedhosts
396
425
 
397
426
  @cache_decorator
398
- def expand_hostnames(hosts,no_env=False):
427
+ def expand_hostnames(hosts):
399
428
  '''
400
429
  Expand the hostnames in the hosts list
401
430
 
@@ -424,17 +453,17 @@ def expand_hostnames(hosts,no_env=False):
424
453
  if re.match(r'^((((25[0-4]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[1-9])|x|\*|\?)(-((25[0-4]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[1-9])|x|\*|\?))?)|(\[((25[0-4]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[1-9])|x|\*|\?)(-((25[0-4]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[1-9])}|x|\*|\?))?\]))(\.((((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])|x|\*|\?)(-((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])|x|\*|\?))?)|(\[((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])|x|\*|\?)(-((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])|x|\*|\?))?\]))){2}(\.(((25[0-4]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[1-9])|x|\*|\?)(-((25[0-4]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[1-9])|x|\*|\?))?)|(\[((25[0-4]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[1-9])|x|\*|\?)(-((25[0-4]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[1-9])}|x|\*|\?))?\]))$', host):
425
454
  hostSetToAdd = sorted(expandIPv4Address(frozenset([host])),key=ipaddress.IPv4Address)
426
455
  else:
427
- hostSetToAdd = sorted(expand_hostname(host,no_env=no_env))
456
+ hostSetToAdd = sorted(expand_hostname(host))
428
457
  if username:
429
458
  # we expand the username
430
- username = sorted(expand_hostname(username,validate=False,no_env=no_env))
459
+ username = sorted(expand_hostname(username,validate=False))
431
460
  # we combine the username and hostname
432
461
  hostSetToAdd = [u+'@'+h for u,h in product(username,hostSetToAdd)]
433
462
  expandedhosts.extend(hostSetToAdd)
434
463
  return expandedhosts
435
464
 
436
465
  @cache_decorator
437
- def validate_expand_hostname(hostname,no_env=False):
466
+ def validate_expand_hostname(hostname):
438
467
  '''
439
468
  Validate the hostname and expand it if it is a range of IP addresses
440
469
 
@@ -444,17 +473,18 @@ def validate_expand_hostname(hostname,no_env=False):
444
473
  Returns:
445
474
  list: A list of valid hostnames
446
475
  '''
476
+ global _no_env
447
477
  # maybe it is just defined in ./target_files/hosts.sh and exported to the environment
448
478
  # we will try to get the valid host name from the environment
449
479
  hostname = hostname.strip('$')
450
480
  if getIP(hostname,local=True):
451
481
  return [hostname]
452
- elif not no_env and hostname in os.environ:
482
+ elif not _no_env and hostname in os.environ:
453
483
  # we will expand these hostnames again
454
- return expand_hostnames(frozenset(os.environ[hostname].split(',')),no_env=no_env)
484
+ return expand_hostnames(frozenset(os.environ[hostname].split(',')))
455
485
  elif hostname in readEnvFromFile():
456
486
  # we will expand these hostnames again
457
- return expand_hostnames(frozenset(readEnvFromFile()[hostname].split(',')),no_env=no_env)
487
+ return expand_hostnames(frozenset(readEnvFromFile()[hostname].split(',')))
458
488
  elif getIP(hostname,local=False):
459
489
  return [hostname]
460
490
  else:
@@ -582,64 +612,129 @@ def ssh_command(host, sem, timeout=60,passwds=None):
582
612
  global _emo
583
613
  global __ERROR_MESSAGES_TO_IGNORE_REGEX
584
614
  global __ipmiiInterfaceIPPrefix
585
- global _sshpassAvailable
586
- with sem:
587
- try:
588
- host.username = None
589
- host.address = host.name
590
- if '@' in host.name:
591
- host.username, host.address = host.name.rsplit('@',1)
592
- if "#HOST#" in host.command.upper() or "#HOSTNAME#" in host.command.upper():
593
- host.command = host.command.replace("#HOST#",host.address).replace("#HOSTNAME#",host.address).replace("#host#",host.address).replace("#hostname#",host.address)
594
- if "#USER#" in host.command.upper() or "#USERNAME#" in host.command.upper():
595
- if host.username:
596
- host.command = host.command.replace("#USER#",host.username).replace("#USERNAME#",host.username).replace("#user#",host.username).replace("#username#",host.username)
597
- else:
598
- host.command = host.command.replace("#USER#",'CURRENT_USER').replace("#USERNAME#",'CURRENT_USER').replace("#user#",'CURRENT_USER').replace("#username#",'CURRENT_USER')
599
- formatedCMD = []
600
- if host.extraargs:
601
- extraargs = host.extraargs.split()
602
- else:
603
- extraargs = []
604
- if __ipmiiInterfaceIPPrefix:
605
- host.interface_ip_prefix = __ipmiiInterfaceIPPrefix if host.ipmi and not host.interface_ip_prefix else host.interface_ip_prefix
606
- if host.interface_ip_prefix:
607
- try:
608
- hostOctets = getIP(host.address,local=False).split('.')
609
- prefixOctets = host.interface_ip_prefix.split('.')
610
- host.address = '.'.join(prefixOctets[:3]+hostOctets[min(3,len(prefixOctets)):])
611
- host.resolvedName = host.username + '@' if host.username else ''
612
- host.resolvedName += host.address
613
- except:
614
- host.resolvedName = host.name
615
+ global _binPaths
616
+ try:
617
+ keyCheckArgs = []
618
+ rsyncKeyCheckArgs = []
619
+ if not SSH_STRICT_HOST_KEY_CHECKING:
620
+ keyCheckArgs = ['-o StrictHostKeyChecking=no','-o UserKnownHostsFile=/dev/null']
621
+ rsyncKeyCheckArgs = ['--rsh','ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null']
622
+ host.username = None
623
+ host.address = host.name
624
+ if '@' in host.name:
625
+ host.username, host.address = host.name.rsplit('@',1)
626
+ if "#HOST#" in host.command.upper() or "#HOSTNAME#" in host.command.upper():
627
+ host.command = host.command.replace("#HOST#",host.address).replace("#HOSTNAME#",host.address).replace("#host#",host.address).replace("#hostname#",host.address)
628
+ if "#USER#" in host.command.upper() or "#USERNAME#" in host.command.upper():
629
+ if host.username:
630
+ host.command = host.command.replace("#USER#",host.username).replace("#USERNAME#",host.username).replace("#user#",host.username).replace("#username#",host.username)
615
631
  else:
632
+ current_user = getpass.getuser()
633
+ host.command = host.command.replace("#USER#",current_user).replace("#USERNAME#",current_user).replace("#user#",current_user).replace("#username#",current_user)
634
+ formatedCMD = []
635
+ if host.extraargs and type(host.extraargs) == str:
636
+ extraargs = host.extraargs.split()
637
+ elif host.extraargs and type(host.extraargs) == list:
638
+ extraargs = [str(arg) for arg in host.extraargs]
639
+ else:
640
+ extraargs = []
641
+ if __ipmiiInterfaceIPPrefix:
642
+ host.interface_ip_prefix = __ipmiiInterfaceIPPrefix if host.ipmi and not host.interface_ip_prefix else host.interface_ip_prefix
643
+ if host.interface_ip_prefix:
644
+ try:
645
+ hostOctets = getIP(host.address,local=False).split('.')
646
+ prefixOctets = host.interface_ip_prefix.split('.')
647
+ host.address = '.'.join(prefixOctets[:3]+hostOctets[min(3,len(prefixOctets)):])
648
+ host.resolvedName = host.username + '@' if host.username else ''
649
+ host.resolvedName += host.address
650
+ except:
616
651
  host.resolvedName = host.name
617
- if host.ipmi:
652
+ else:
653
+ host.resolvedName = host.name
654
+ if host.ipmi:
655
+ if 'ipmitool' in _binPaths:
618
656
  if host.command.startswith('ipmitool '):
619
657
  host.command = host.command.replace('ipmitool ','')
658
+ elif host.command.startswith(_binPaths['ipmitool']):
659
+ host.command = host.command.replace(_binPaths['ipmitool'],'')
620
660
  if not host.username:
621
661
  host.username = 'admin'
622
- if passwds:
623
- formatedCMD = ['bash','-c',f'ipmitool -H {host.address} -U {host.username} -P {passwds} {" ".join(extraargs)} {host.command}']
662
+ if 'bash' in _binPaths:
663
+ if passwds:
664
+ formatedCMD = [_binPaths['bash'],'-c',f'ipmitool -H {host.address} -U {host.username} -P {passwds} {" ".join(extraargs)} {host.command}']
665
+ else:
666
+ formatedCMD = [_binPaths['bash'],'-c',f'ipmitool -H {host.address} -U {host.username} {" ".join(extraargs)} {host.command}']
624
667
  else:
625
- formatedCMD = ['bash','-c',f'ipmitool -H {host.address} -U {host.username} {" ".join(extraargs)} {host.command}']
668
+ if passwds:
669
+ formatedCMD = [_binPaths['ipmitool'],f'-H {host.address}',f'-U {host.username}',f'-P {passwds}'] + extraargs + [host.command]
670
+ else:
671
+ formatedCMD = [_binPaths['ipmitool'],f'-H {host.address}',f'-U {host.username}'] + extraargs + [host.command]
672
+ elif 'ssh' in _binPaths:
673
+ host.output.append('Ipmitool not found on the local machine! Trying ipmitool on the remote machine...')
674
+ host.ipmi = False
675
+ host.interface_ip_prefix = None
676
+ host.command = 'ipmitool '+host.command if not host.command.startswith('ipmitool ') else host.command
677
+ ssh_command(host,sem,timeout,passwds)
678
+ return
626
679
  else:
627
- if host.files:
628
- if host.scp:
629
- formatedCMD = ['scp','-rpB'] + extraargs +['--']+host.files+[f'{host.resolvedName}:{host.command}']
680
+ host.output.append('Ipmitool not found on the local machine! Please install ipmitool to use ipmi mode.')
681
+ host.stderr.append('Ipmitool not found on the local machine! Please install ipmitool to use ipmi mode.')
682
+ host.returncode = 1
683
+ return
684
+ else:
685
+ if host.files:
686
+ if host.scp:
687
+ if 'scp' in _binPaths:
688
+ useScp = True
689
+ elif 'rsync' in _binPaths:
690
+ host.output.append('scp not found on the local machine! Trying to use rsync...')
691
+ useScp = False
630
692
  else:
631
- formatedCMD = ['rsync','-ahlX','--partial','--inplace', '--info=name'] + extraargs +['--']+host.files+[f'{host.resolvedName}:{host.command}']
693
+ host.output.append('scp not found on the local machine! Please install scp or rsync to use file sync mode.')
694
+ host.stderr.append('scp not found on the local machine! Please install scp or rsync to use file sync mode.')
695
+ host.returncode = 1
696
+ return
697
+ elif 'rsync' in _binPaths:
698
+ useScp = False
699
+ elif 'scp' in _binPaths:
700
+ host.output.append('rsync not found on the local machine! Trying to use scp...')
701
+ useScp = True
632
702
  else:
633
- formatedCMD = ['ssh'] + extraargs +['--']+ [host.resolvedName, host.command]
634
- if passwds and _sshpassAvailable:
635
- formatedCMD = ['sshpass', '-p', passwds] + formatedCMD
636
- elif passwds:
637
- host.output.append('Warning: sshpass is not available. Please install sshpass to use password authentication.')
638
- #host.stderr.append('Warning: sshpass is not available. Please install sshpass to use password authentication.')
639
- host.output.append('Please provide password via live input or use ssh key authentication.')
640
- # # try to send the password via __keyPressesIn
641
- # __keyPressesIn[-1] = list(passwds) + ['\n']
642
- # __keyPressesIn.append([])
703
+ host.output.append('rsync not found on the local machine! Please install rsync or scp to use file sync mode.')
704
+ host.stderr.append('rsync not found on the local machine! Please install rsync or scp to use file sync mode.')
705
+ host.returncode = 1
706
+ return
707
+ if host.gatherMode:
708
+ fileArgs = [f'{host.resolvedName}:{file}' for file in host.files] + [host.command]
709
+ else:
710
+ fileArgs = host.files + [f'{host.resolvedName}:{host.command}']
711
+ if useScp:
712
+ formatedCMD = [_binPaths['scp'],'-rpB'] + keyCheckArgs + extraargs +['--']+fileArgs
713
+ else:
714
+ formatedCMD = [_binPaths['rsync'],'-ahlX','--partial','--inplace', '--info=name'] + rsyncKeyCheckArgs + extraargs +['--']+fileArgs
715
+ else:
716
+ formatedCMD = [_binPaths['ssh']] + keyCheckArgs + extraargs +['--']+ [host.resolvedName, host.command]
717
+ if passwds and 'sshpass' in _binPaths:
718
+ formatedCMD = [_binPaths['sshpass'], '-p', passwds] + formatedCMD
719
+ elif passwds:
720
+ host.output.append('Warning: sshpass is not available. Please install sshpass to use password authentication.')
721
+ #host.stderr.append('Warning: sshpass is not available. Please install sshpass to use password authentication.')
722
+ host.output.append('Please provide password via live input or use ssh key authentication.')
723
+ # # try to send the password via __keyPressesIn
724
+ # __keyPressesIn[-1] = list(passwds) + ['\n']
725
+ # __keyPressesIn.append([])
726
+ except Exception as e:
727
+ import traceback
728
+ host.output.append(f'Error occurred while formatting the command : {host.command}!')
729
+ host.stderr.append(f'Error occurred while formatting the command : {host.command}!')
730
+ host.stderr.extend(str(e).split('\n'))
731
+ host.output.extend(str(e).split('\n'))
732
+ host.stderr.extend(traceback.format_exc().split('\n'))
733
+ host.output.extend(traceback.format_exc().split('\n'))
734
+ host.returncode = -1
735
+ return
736
+ with sem:
737
+ try:
643
738
  host.output.append('Running command: '+' '.join(formatedCMD))
644
739
  #host.stdout = []
645
740
  proc = subprocess.Popen(formatedCMD,stdout=subprocess.PIPE,stderr=subprocess.PIPE,stdin=subprocess.PIPE)
@@ -733,7 +828,7 @@ def ssh_command(host, sem, timeout=60,passwds=None):
733
828
  host.command = 'ipmitool '+host.command if not host.command.startswith('ipmitool ') else host.command
734
829
  ssh_command(host,sem,timeout,passwds)
735
830
  # If transfering files, we will try again using scp if rsync connection is not successful
736
- if host.files and not host.scp and host.returncode != 0 and host.stderr:
831
+ if host.files and not host.scp and not useScp and host.returncode != 0 and host.stderr:
737
832
  host.stderr = []
738
833
  host.stdout = []
739
834
  host.output.append('Rsync connection failed! Trying SCP connection...')
@@ -1143,34 +1238,34 @@ def print_output(hosts,usejson = False,quiet = False,greppable = False):
1143
1238
  print(rtnStr)
1144
1239
  return rtnStr
1145
1240
 
1146
- sshConfigged = False
1147
- def verify_ssh_config():
1148
- '''
1149
- Verify that ~/.ssh/config exists and contains the line "StrictHostKeyChecking no"
1150
-
1151
- Args:
1152
- None
1153
-
1154
- Returns:
1155
- None
1156
- '''
1157
- global sshConfigged
1158
- if not sshConfigged:
1159
- # first we make sure ~/.ssh/config exists
1160
- config = ''
1161
- if not os.path.exists(os.path.expanduser('~/.ssh')):
1162
- os.makedirs(os.path.expanduser('~/.ssh'))
1163
- if os.path.exists(os.path.expanduser('~/.ssh/config')):
1164
- with open(os.path.expanduser('~/.ssh/config'),'r') as f:
1165
- config = f.read()
1166
- if config:
1167
- if 'StrictHostKeyChecking no' not in config:
1168
- with open(os.path.expanduser('~/.ssh/config'),'a') as f:
1169
- f.write('\nHost *\n\tStrictHostKeyChecking no\n')
1170
- else:
1171
- with open(os.path.expanduser('~/.ssh/config'),'w') as f:
1172
- f.write('Host *\n\tStrictHostKeyChecking no\n')
1173
- sshConfigged = True
1241
+ # sshConfigged = False
1242
+ # def verify_ssh_config():
1243
+ # '''
1244
+ # Verify that ~/.ssh/config exists and contains the line "StrictHostKeyChecking no"
1245
+
1246
+ # Args:
1247
+ # None
1248
+
1249
+ # Returns:
1250
+ # None
1251
+ # '''
1252
+ # global sshConfigged
1253
+ # if not sshConfigged:
1254
+ # # first we make sure ~/.ssh/config exists
1255
+ # config = ''
1256
+ # if not os.path.exists(os.path.expanduser('~/.ssh')):
1257
+ # os.makedirs(os.path.expanduser('~/.ssh'))
1258
+ # if os.path.exists(os.path.expanduser('~/.ssh/config')):
1259
+ # with open(os.path.expanduser('~/.ssh/config'),'r') as f:
1260
+ # config = f.read()
1261
+ # if config:
1262
+ # if 'StrictHostKeyChecking no' not in config:
1263
+ # with open(os.path.expanduser('~/.ssh/config'),'a') as f:
1264
+ # f.write('\nHost *\n\tStrictHostKeyChecking no\n')
1265
+ # else:
1266
+ # with open(os.path.expanduser('~/.ssh/config'),'w') as f:
1267
+ # f.write('Host *\n\tStrictHostKeyChecking no\n')
1268
+ # sshConfigged = True
1174
1269
 
1175
1270
  def signal_handler(sig, frame):
1176
1271
  '''
@@ -1194,9 +1289,9 @@ def signal_handler(sig, frame):
1194
1289
  os.system(f'pkill -ef {os.path.basename(__file__)}')
1195
1290
  sys.exit(0)
1196
1291
 
1197
-
1198
1292
  def processRunOnHosts(timeout, password, max_connections, hosts, returnUnfinished, nowatch, json, called, greppable,unavailableHosts,willUpdateUnreachableHosts,curses_min_char_len = DEFAULT_CURSES_MINIMUM_CHAR_LEN, curses_min_line_len = DEFAULT_CURSES_MINIMUM_LINE_LEN,single_window = DEFAULT_SINGLE_WINDOW):
1199
- global __gloablUnavailableHosts
1293
+ global __globalUnavailableHosts
1294
+ global _no_env
1200
1295
  threads = start_run_on_hosts(hosts, timeout=timeout,password=password,max_connections=max_connections)
1201
1296
  if not nowatch and threads and not returnUnfinished and any([thread.is_alive() for thread in threads]) and sys.stdout.isatty() and os.get_terminal_size() and os.get_terminal_size().columns > 10:
1202
1297
  curses.wrapper(curses_print, hosts, threads, min_char_len = curses_min_char_len, min_line_len = curses_min_line_len, single_window = single_window)
@@ -1209,7 +1304,11 @@ def processRunOnHosts(timeout, password, max_connections, hosts, returnUnfinishe
1209
1304
  # update the unavailable hosts and global unavailable hosts
1210
1305
  if willUpdateUnreachableHosts:
1211
1306
  unavailableHosts.update([host.name for host in hosts if host.stderr and ('No route to host' in host.stderr[0].strip() or host.stderr[0].strip().startswith('Timeout!'))])
1212
- __gloablUnavailableHosts.update(unavailableHosts)
1307
+ __globalUnavailableHosts.update(unavailableHosts)
1308
+ # update the os environment variable if not _no_env
1309
+ if not _no_env:
1310
+ os.environ['__multiSSH3_UNAVAILABLE_HOSTS'] = ','.join(unavailableHosts)
1311
+
1213
1312
  # print the output, if the output of multiple hosts are the same, we aggragate them
1214
1313
  if not called:
1215
1314
  print_output(hosts,json,greppable=greppable)
@@ -1242,7 +1341,7 @@ def formHostStr(host) -> str:
1242
1341
  def __formCommandArgStr(oneonone = DEFAULT_ONE_ON_ONE, timeout = DEFAULT_TIMEOUT,password = DEFAULT_PASSWORD,
1243
1342
  nowatch = DEFAULT_NO_WATCH,json = DEFAULT_JSON_MODE,max_connections=DEFAULT_MAX_CONNECTIONS,
1244
1343
  files = None,ipmi = DEFAULT_IPMI,interface_ip_prefix = DEFAULT_INTERFACE_IP_PREFIX,
1245
- scp=DEFAULT_SCP,username=DEFAULT_USERNAME,extraargs=DEFAULT_EXTRA_ARGS,skipUnreachable=DEFAULT_SKIP_UNREACHABLE,
1344
+ scp=DEFAULT_SCP,gather_mode = False,username=DEFAULT_USERNAME,extraargs=DEFAULT_EXTRA_ARGS,skipUnreachable=DEFAULT_SKIP_UNREACHABLE,
1246
1345
  no_env=DEFAULT_NO_ENV,greppable=DEFAULT_GREPPABLE_MODE,skip_hosts = DEFAULT_SKIP_HOSTS,
1247
1346
  file_sync = False, error_only = DEFAULT_ERROR_ONLY,
1248
1347
  shortend = False) -> str:
@@ -1257,6 +1356,7 @@ def __formCommandArgStr(oneonone = DEFAULT_ONE_ON_ONE, timeout = DEFAULT_TIMEOUT
1257
1356
  if ipmi: argsList.append('--ipmi')
1258
1357
  if interface_ip_prefix and interface_ip_prefix != DEFAULT_INTERFACE_IP_PREFIX: argsList.append(f'--interface_ip_prefix="{interface_ip_prefix}"' if not shortend else f'-pre="{interface_ip_prefix}"')
1259
1358
  if scp: argsList.append('--scp')
1359
+ if gather_mode: argsList.append('--gather_mode' if not shortend else '-gm')
1260
1360
  if username and username != DEFAULT_USERNAME: argsList.append(f'--username="{username}"' if not shortend else f'-u="{username}"')
1261
1361
  if extraargs and extraargs != DEFAULT_EXTRA_ARGS: argsList.append(f'--extraargs="{extraargs}"' if not shortend else f'-ea="{extraargs}"')
1262
1362
  if skipUnreachable: argsList.append('--skipUnreachable' if not shortend else '-su')
@@ -1270,7 +1370,7 @@ def __formCommandArgStr(oneonone = DEFAULT_ONE_ON_ONE, timeout = DEFAULT_TIMEOUT
1270
1370
  def getStrCommand(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAULT_ONE_ON_ONE, timeout = DEFAULT_TIMEOUT,password = DEFAULT_PASSWORD,
1271
1371
  nowatch = DEFAULT_NO_WATCH,json = DEFAULT_JSON_MODE,called = _DEFAULT_CALLED,max_connections=DEFAULT_MAX_CONNECTIONS,
1272
1372
  files = None,ipmi = DEFAULT_IPMI,interface_ip_prefix = DEFAULT_INTERFACE_IP_PREFIX,returnUnfinished = _DEFAULT_RETURN_UNFINISHED,
1273
- scp=DEFAULT_SCP,username=DEFAULT_USERNAME,extraargs=DEFAULT_EXTRA_ARGS,skipUnreachable=DEFAULT_SKIP_UNREACHABLE,
1373
+ scp=DEFAULT_SCP,gather_mode = False,username=DEFAULT_USERNAME,extraargs=DEFAULT_EXTRA_ARGS,skipUnreachable=DEFAULT_SKIP_UNREACHABLE,
1274
1374
  no_env=DEFAULT_NO_ENV,greppable=DEFAULT_GREPPABLE_MODE,willUpdateUnreachableHosts=_DEFAULT_UPDATE_UNREACHABLE_HOSTS,no_start=_DEFAULT_NO_START,
1275
1375
  skip_hosts = DEFAULT_SKIP_HOSTS, curses_min_char_len = DEFAULT_CURSES_MINIMUM_CHAR_LEN, curses_min_line_len = DEFAULT_CURSES_MINIMUM_LINE_LEN,
1276
1376
  single_window = DEFAULT_SINGLE_WINDOW,file_sync = False,error_only = DEFAULT_ERROR_ONLY, shortend = False):
@@ -1279,7 +1379,7 @@ def getStrCommand(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAULT_ONE_O
1279
1379
  files = frozenset(files) if files else None
1280
1380
  argsStr = __formCommandArgStr(oneonone = oneonone, timeout = timeout,password = password,
1281
1381
  nowatch = nowatch,json = json,max_connections=max_connections,
1282
- files = files,ipmi = ipmi,interface_ip_prefix = interface_ip_prefix,scp=scp,
1382
+ files = files,ipmi = ipmi,interface_ip_prefix = interface_ip_prefix,scp=scp,gather_mode = gather_mode,
1283
1383
  username=username,extraargs=extraargs,skipUnreachable=skipUnreachable,no_env=no_env,
1284
1384
  greppable=greppable,skip_hosts = skip_hosts, file_sync = file_sync,error_only = error_only, shortend = shortend)
1285
1385
  commandStr = '"' + '" "'.join(commands) + '"' if commands else ''
@@ -1288,7 +1388,7 @@ def getStrCommand(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAULT_ONE_O
1288
1388
  def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAULT_ONE_ON_ONE, timeout = DEFAULT_TIMEOUT,password = DEFAULT_PASSWORD,
1289
1389
  nowatch = DEFAULT_NO_WATCH,json = DEFAULT_JSON_MODE,called = _DEFAULT_CALLED,max_connections=DEFAULT_MAX_CONNECTIONS,
1290
1390
  files = None,ipmi = DEFAULT_IPMI,interface_ip_prefix = DEFAULT_INTERFACE_IP_PREFIX,returnUnfinished = _DEFAULT_RETURN_UNFINISHED,
1291
- scp=DEFAULT_SCP,username=DEFAULT_USERNAME,extraargs=DEFAULT_EXTRA_ARGS,skipUnreachable=DEFAULT_SKIP_UNREACHABLE,
1391
+ scp=DEFAULT_SCP,gather_mode = False,username=DEFAULT_USERNAME,extraargs=DEFAULT_EXTRA_ARGS,skipUnreachable=DEFAULT_SKIP_UNREACHABLE,
1292
1392
  no_env=DEFAULT_NO_ENV,greppable=DEFAULT_GREPPABLE_MODE,willUpdateUnreachableHosts=_DEFAULT_UPDATE_UNREACHABLE_HOSTS,no_start=_DEFAULT_NO_START,
1293
1393
  skip_hosts = DEFAULT_SKIP_HOSTS, curses_min_char_len = DEFAULT_CURSES_MINIMUM_CHAR_LEN, curses_min_line_len = DEFAULT_CURSES_MINIMUM_LINE_LEN,
1294
1394
  single_window = DEFAULT_SINGLE_WINDOW,file_sync = False,error_only = DEFAULT_ERROR_ONLY):
@@ -1310,6 +1410,7 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
1310
1410
  interface_ip_prefix (str, optional): The prefix of the IPMI interface. Defaults to {DEFAULT_INTERFACE_IP_PREFIX}.
1311
1411
  returnUnfinished (bool, optional): Whether to return the unfinished hosts. Defaults to {_DEFAULT_RETURN_UNFINISHED}.
1312
1412
  scp (bool, optional): Whether to use scp instead of rsync. Defaults to {DEFAULT_SCP}.
1413
+ gather_mode (bool, optional): Whether to use gather mode. Defaults to False.
1313
1414
  username (str, optional): The username to use to connect to the hosts. Defaults to {DEFAULT_USERNAME}.
1314
1415
  extraargs (str, optional): Extra arguments to pass to the ssh / rsync / scp command. Defaults to {DEFAULT_EXTRA_ARGS}.
1315
1416
  skipUnreachable (bool, optional): Whether to skip unreachable hosts. Defaults to {DEFAULT_SKIP_UNREACHABLE}.
@@ -1326,8 +1427,16 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
1326
1427
  Returns:
1327
1428
  list: A list of Host objects
1328
1429
  '''
1329
- global __gloablUnavailableHosts
1430
+ global __globalUnavailableHosts
1330
1431
  global __global_suppress_printout
1432
+ global _no_env
1433
+ global _emo
1434
+ _emo = False
1435
+ _no_env = no_env
1436
+ if not no_env and '__multiSSH3_UNAVAILABLE_HOSTS' in os.environ:
1437
+ __globalUnavailableHosts = set(os.environ['__multiSSH3_UNAVAILABLE_HOSTS'].split(','))
1438
+ elif '__multiSSH3_UNAVAILABLE_HOSTS' in readEnvFromFile():
1439
+ __globalUnavailableHosts = set(readEnvFromFile()['__multiSSH3_UNAVAILABLE_HOSTS'].split(','))
1331
1440
  if not max_connections:
1332
1441
  max_connections = 4 * os.cpu_count()
1333
1442
  elif max_connections == 0:
@@ -1336,7 +1445,7 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
1336
1445
  max_connections = (-max_connections) * os.cpu_count()
1337
1446
  if not commands:
1338
1447
  commands = []
1339
- verify_ssh_config()
1448
+ #verify_ssh_config()
1340
1449
  # load global unavailable hosts only if the function is called (so using --repeat will not load the unavailable hosts again)
1341
1450
  if called:
1342
1451
  # if called,
@@ -1345,18 +1454,17 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
1345
1454
  if skipUnreachable is None:
1346
1455
  skipUnreachable = True
1347
1456
  if skipUnreachable:
1348
- unavailableHosts = __gloablUnavailableHosts
1457
+ unavailableHosts = __globalUnavailableHosts
1349
1458
  else:
1350
1459
  unavailableHosts = set()
1351
1460
  else:
1352
1461
  # if run in command line ( or emulating running in command line, we default to skip unreachable hosts within one command call )
1353
1462
  if skipUnreachable:
1354
- unavailableHosts = __gloablUnavailableHosts
1463
+ unavailableHosts = __globalUnavailableHosts
1355
1464
  else:
1356
1465
  unavailableHosts = set()
1357
1466
  skipUnreachable = True
1358
- global _emo
1359
- _emo = False
1467
+
1360
1468
  # We create the hosts
1361
1469
  hostStr = formHostStr(hosts)
1362
1470
  skipHostStr = formHostStr(skip_hosts) if skip_hosts else ''
@@ -1375,8 +1483,8 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
1375
1483
  if '@' not in host:
1376
1484
  skipHostStr[i] = userStr + host
1377
1485
  skipHostStr = ','.join(skipHostStr)
1378
- targetHostsList = expand_hostnames(frozenset(hostStr.split(',')),no_env=no_env)
1379
- skipHostsList = expand_hostnames(frozenset(skipHostStr.split(',')),no_env=no_env)
1486
+ targetHostsList = expand_hostnames(frozenset(hostStr.split(',')))
1487
+ skipHostsList = expand_hostnames(frozenset(skipHostStr.split(',')))
1380
1488
  if skipHostsList:
1381
1489
  if not __global_suppress_printout: print(f"Skipping hosts: {skipHostsList}")
1382
1490
  if files and not commands:
@@ -1387,15 +1495,18 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
1387
1495
  files = set(files+commands) if files else set(commands)
1388
1496
  if files:
1389
1497
  # try to resolve files first (like * etc)
1390
- pathSet = set()
1391
- for file in files:
1392
- try:
1393
- pathSet.update(glob.glob(file,include_hidden=True,recursive=True))
1394
- except:
1395
- pathSet.update(glob.glob(file,recursive=True))
1396
- if not pathSet:
1397
- print(f'Warning: No source files at {files} are found after resolving globs!')
1398
- sys.exit(66)
1498
+ if not gather_mode:
1499
+ pathSet = set()
1500
+ for file in files:
1501
+ try:
1502
+ pathSet.update(glob.glob(file,include_hidden=True,recursive=True))
1503
+ except:
1504
+ pathSet.update(glob.glob(file,recursive=True))
1505
+ if not pathSet:
1506
+ print(f'Warning: No source files at {files} are found after resolving globs!')
1507
+ sys.exit(66)
1508
+ else:
1509
+ pathSet = set(files)
1399
1510
  if file_sync:
1400
1511
  # use abosolute path for file sync
1401
1512
  commands = [os.path.abspath(file) for file in pathSet]
@@ -1418,9 +1529,9 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
1418
1529
  continue
1419
1530
  if host.strip() in skipHostsList: continue
1420
1531
  if file_sync:
1421
- hosts.append(Host(host.strip(), os.path.dirname(command)+os.path.sep, files = [command],ipmi=ipmi,interface_ip_prefix=interface_ip_prefix,scp=scp,extraargs=extraargs))
1532
+ hosts.append(Host(host.strip(), os.path.dirname(command)+os.path.sep, files = [command],ipmi=ipmi,interface_ip_prefix=interface_ip_prefix,scp=scp,extraargs=extraargs,gatherMode=gather_mode))
1422
1533
  else:
1423
- hosts.append(Host(host.strip(), command, files = files,ipmi=ipmi,interface_ip_prefix=interface_ip_prefix,scp=scp,extraargs=extraargs))
1534
+ hosts.append(Host(host.strip(), command, files = files,ipmi=ipmi,interface_ip_prefix=interface_ip_prefix,scp=scp,extraargs=extraargs,gatherMode=gather_mode))
1424
1535
  if not __global_suppress_printout:
1425
1536
  print(f"Running command: {command} on host: {host}")
1426
1537
  if not __global_suppress_printout: print('-'*80)
@@ -1462,9 +1573,9 @@ def run_command_on_hosts(hosts = DEFAULT_HOSTS,commands = None,oneonone = DEFAUL
1462
1573
  continue
1463
1574
  if host.strip() in skipHostsList: continue
1464
1575
  if file_sync:
1465
- hosts.append(Host(host.strip(), os.path.dirname(command)+os.path.sep, files = [command],ipmi=ipmi,interface_ip_prefix=interface_ip_prefix,scp=scp,extraargs=extraargs))
1576
+ hosts.append(Host(host.strip(), os.path.dirname(command)+os.path.sep, files = [command],ipmi=ipmi,interface_ip_prefix=interface_ip_prefix,scp=scp,extraargs=extraargs,gatherMode=gather_mode))
1466
1577
  else:
1467
- hosts.append(Host(host.strip(), command, files = files,ipmi=ipmi,interface_ip_prefix=interface_ip_prefix,scp=scp,extraargs=extraargs))
1578
+ hosts.append(Host(host.strip(), command, files = files,ipmi=ipmi,interface_ip_prefix=interface_ip_prefix,scp=scp,extraargs=extraargs,gatherMode=gather_mode))
1468
1579
  if not __global_suppress_printout and len(commands) > 1:
1469
1580
  print('-'*80)
1470
1581
  print(f"Running command: {command} on hosts: {hostStr}" + (f"; skipping: {skipHostStr}" if skipHostStr else ''))
@@ -1514,6 +1625,7 @@ def get_default_config(args):
1514
1625
  'DEFAULT_GREPPABLE_MODE': args.greppable,
1515
1626
  'DEFAULT_SKIP_UNREACHABLE': args.skip_unreachable,
1516
1627
  'DEFAULT_SKIP_HOSTS': args.skip_hosts,
1628
+ 'SSH_STRICT_HOST_KEY_CHECKING': SSH_STRICT_HOST_KEY_CHECKING,
1517
1629
  'ERROR_MESSAGES_TO_IGNORE': ERROR_MESSAGES_TO_IGNORE,
1518
1630
  }
1519
1631
 
@@ -1521,18 +1633,19 @@ def write_default_config(args,CONFIG_FILE,backup = True):
1521
1633
  if backup and os.path.exists(CONFIG_FILE):
1522
1634
  os.rename(CONFIG_FILE,CONFIG_FILE+'.bak')
1523
1635
  default_config = get_default_config(args)
1636
+ # apply the updated defualt_config to __configs_from_file and write that to file
1637
+ __configs_from_file.update(default_config)
1524
1638
  with open(CONFIG_FILE,'w') as f:
1525
- json.dump(default_config,f,indent=4)
1639
+ json.dump(__configs_from_file,f,indent=4)
1526
1640
 
1527
1641
 
1528
1642
  def main():
1529
1643
  global _emo
1530
1644
  global __global_suppress_printout
1531
- global __gloablUnavailableHosts
1532
1645
  global __mainReturnCode
1533
1646
  global __failedHosts
1534
1647
  global __ipmiiInterfaceIPPrefix
1535
- global _sshpassAvailable
1648
+ global _binPaths
1536
1649
  global _env_file
1537
1650
  _emo = False
1538
1651
  # We handle the signal
@@ -1548,6 +1661,7 @@ def main():
1548
1661
  parser.add_argument("-f","--file", action='append', help="The file to be copied to the hosts. Use -f multiple times to copy multiple files")
1549
1662
  parser.add_argument('--file_sync', action='store_true', help=f'Operate in file sync mode, sync path in <COMMANDS> from this machine to <HOSTS>. Treat --file <FILE> and <COMMANDS> both as source as source and destination will be the same in this mode. (default: {DEFAULT_FILE_SYNC})', default=DEFAULT_FILE_SYNC)
1550
1663
  parser.add_argument('--scp', action='store_true', help=f'Use scp for copying files instead of rsync. Need to use this on windows. (default: {DEFAULT_SCP})', default=DEFAULT_SCP)
1664
+ parser.add_argument('-gm','--gather_mode', action='store_true', help=f'Gather files from the hosts instead of sending files to the hosts. Will send remote files specified in <FILE> to local path specified in <COMMANDS> (default: False)', default=False)
1551
1665
  #parser.add_argument("-d",'-c',"--destination", type=str, help="The destination of the files. Same as specify with commands. Added for compatibility. Use #HOST# or #HOSTNAME# to replace the host name in the destination")
1552
1666
  parser.add_argument("-t","--timeout", type=int, help=f"Timeout for each command in seconds (default: {DEFAULT_CLI_TIMEOUT} (disabled))", default=DEFAULT_CLI_TIMEOUT)
1553
1667
  parser.add_argument("-r","--repeat", type=int, help=f"Repeat the command for a number of times (default: {DEFAULT_REPEAT})", default=DEFAULT_REPEAT)
@@ -1561,22 +1675,22 @@ def main():
1561
1675
  parser.add_argument('-sw','--single_window', action='store_true', help=f'Use a single window for all hosts. (default: {DEFAULT_SINGLE_WINDOW})', default=DEFAULT_SINGLE_WINDOW)
1562
1676
  parser.add_argument('-eo','--error_only', action='store_true', help=f'Only print the error output. (default: {DEFAULT_ERROR_ONLY})', default=DEFAULT_ERROR_ONLY)
1563
1677
  parser.add_argument("-no","--no_output", action='store_true', help=f"Do not print the output. (default: {DEFAULT_NO_OUTPUT})", default=DEFAULT_NO_OUTPUT)
1564
- parser.add_argument('--no_env', action='store_true', help=f'Do not load the environment variables. (default: {DEFAULT_NO_ENV})', default=DEFAULT_NO_ENV)
1565
- parser.add_argument("--env_file", type=str, help=f"The file to load the environment variables from. (default: {DEFAULT_ENV_FILE})", default=DEFAULT_ENV_FILE)
1678
+ parser.add_argument('--no_env', action='store_true', help=f'Do not load the command line environment variables. (default: {DEFAULT_NO_ENV})', default=DEFAULT_NO_ENV)
1679
+ parser.add_argument("--env_file", type=str, help=f"The file to load the mssh file based environment variables from. ( Still work with --no_env ) (default: {DEFAULT_ENV_FILE})", default=DEFAULT_ENV_FILE)
1566
1680
  parser.add_argument("-m","--max_connections", type=int, help=f"Max number of connections to use (default: 4 * cpu_count)", default=DEFAULT_MAX_CONNECTIONS)
1567
1681
  parser.add_argument("-j","--json", action='store_true', help=F"Output in json format. (default: {DEFAULT_JSON_MODE})", default=DEFAULT_JSON_MODE)
1568
1682
  parser.add_argument("--success_hosts", action='store_true', help=f"Output the hosts that succeeded in summary as wells. (default: {DEFAULT_PRINT_SUCCESS_HOSTS})", default=DEFAULT_PRINT_SUCCESS_HOSTS)
1569
1683
  parser.add_argument("-g","--greppable", action='store_true', help=f"Output in greppable format. (default: {DEFAULT_GREPPABLE_MODE})", default=DEFAULT_GREPPABLE_MODE)
1570
1684
  parser.add_argument("-su","--skip_unreachable", action='store_true', help=f"Skip unreachable hosts while using --repeat. Note: Timedout Hosts are considered unreachable. Note: multiple command sequence will still auto skip unreachable hosts. (default: {DEFAULT_SKIP_UNREACHABLE})", default=DEFAULT_SKIP_UNREACHABLE)
1571
1685
  parser.add_argument("-sh","--skip_hosts", type=str, help=f"Skip the hosts in the list. (default: {DEFAULT_SKIP_HOSTS if DEFAULT_SKIP_HOSTS else 'None'})", default=DEFAULT_SKIP_HOSTS)
1572
- parser.add_argument('--generate_default_config_file', action='store_true', help=f'Generate / store the default config file from command line argument and current config at {CONFIG_FILE}')
1573
- parser.add_argument("-V","--version", action='version', version=f'%(prog)s {version} {("with sshpass " if _sshpassAvailable else "")}by {AUTHOR} ({AUTHOR_EMAIL})')
1686
+ parser.add_argument('--store_config_file', action='store_true', help=f'Store / generate the default config file from command line argument and current config at {CONFIG_FILE}')
1687
+ parser.add_argument("-V","--version", action='version', version=f'%(prog)s {version} with [ {", ".join(_binPaths.keys())} ] by {AUTHOR} ({AUTHOR_EMAIL})')
1574
1688
 
1575
1689
  # parser.add_argument('-u', '--user', metavar='user', type=str, nargs=1,
1576
1690
  # help='the user to use to connect to the hosts')
1577
1691
  args = parser.parse_args()
1578
1692
 
1579
- if args.generate_default_config_file:
1693
+ if args.store_config_file:
1580
1694
  try:
1581
1695
  if os.path.exists(CONFIG_FILE):
1582
1696
  print(f"Warning: {CONFIG_FILE} already exists, what to do? (o/b/n)")
@@ -1596,6 +1710,8 @@ def main():
1596
1710
  except Exception as e:
1597
1711
  print(f"Error while writing config file: {e}")
1598
1712
  if not args.commands:
1713
+ with open(CONFIG_FILE,'r') as f:
1714
+ print(f"Config file content: \n{f.read()}")
1599
1715
  sys.exit(0)
1600
1716
 
1601
1717
  _env_file = args.env_file
@@ -1623,7 +1739,7 @@ def main():
1623
1739
  if not __global_suppress_printout:
1624
1740
  print('> ' + getStrCommand(args.hosts,args.commands,oneonone=args.oneonone,timeout=args.timeout,password=args.password,
1625
1741
  nowatch=args.nowatch,json=args.json,called=args.no_output,max_connections=args.max_connections,
1626
- files=args.file,file_sync=args.file_sync,ipmi=args.ipmi,interface_ip_prefix=args.interface_ip_prefix,scp=args.scp,username=args.username,
1742
+ files=args.file,file_sync=args.file_sync,ipmi=args.ipmi,interface_ip_prefix=args.interface_ip_prefix,scp=args.scp,gather_mode = args.gather_mode,username=args.username,
1627
1743
  extraargs=args.extraargs,skipUnreachable=args.skip_unreachable,no_env=args.no_env,greppable=args.greppable,skip_hosts = args.skip_hosts,
1628
1744
  curses_min_char_len = args.window_width, curses_min_line_len = args.window_height,single_window=args.single_window,error_only=args.error_only))
1629
1745
  if args.error_only:
@@ -1638,7 +1754,7 @@ def main():
1638
1754
  hosts = run_command_on_hosts(args.hosts,args.commands,
1639
1755
  oneonone=args.oneonone,timeout=args.timeout,password=args.password,
1640
1756
  nowatch=args.nowatch,json=args.json,called=args.no_output,max_connections=args.max_connections,
1641
- files=args.file,file_sync=args.file_sync,ipmi=args.ipmi,interface_ip_prefix=args.interface_ip_prefix,scp=args.scp,username=args.username,
1757
+ files=args.file,file_sync=args.file_sync,ipmi=args.ipmi,interface_ip_prefix=args.interface_ip_prefix,scp=args.scp,gather_mode = args.gather_mode,username=args.username,
1642
1758
  extraargs=args.extraargs,skipUnreachable=args.skip_unreachable,no_env=args.no_env,greppable=args.greppable,skip_hosts = args.skip_hosts,
1643
1759
  curses_min_char_len = args.window_width, curses_min_line_len = args.window_height,single_window=args.single_window,error_only=args.error_only)
1644
1760
  #print('*'*80)
@@ -1,7 +0,0 @@
1
- multiSSH3.py,sha256=Nerzo1GexP3GoZv0uhjydtu2zSpw4vY2k4Rx0F3yD8M,81707
2
- multiSSH3-4.81.dist-info/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
3
- multiSSH3-4.81.dist-info/METADATA,sha256=GnSUBBxKq9cvqK4Z2cMVsOqWMgjqL_vpEO5XOZ0mNV8,16515
4
- multiSSH3-4.81.dist-info/WHEEL,sha256=GV9aMThwP_4oNCtvEC2ec3qUYutgWeAzklro_0m4WJQ,91
5
- multiSSH3-4.81.dist-info/entry_points.txt,sha256=xi2rWWNfmHx6gS8Mmx0rZL2KZz6XWBYP3DWBpWAnnZ0,143
6
- multiSSH3-4.81.dist-info/top_level.txt,sha256=tUwttxlnpLkZorSsroIprNo41lYSxjd2ASuL8-EJIJw,10
7
- multiSSH3-4.81.dist-info/RECORD,,