hios-ssh-runner 1.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.
@@ -0,0 +1,7 @@
1
+ """hios-ssh-runner - run CLI commands on Hirschmann HiOS switches over SSH.
2
+
3
+ Copyright (C) 2026 Pavel Dvořák
4
+ SPDX-License-Identifier: AGPL-3.0-only
5
+ """
6
+
7
+ __version__ = "1.0.0"
@@ -0,0 +1,11 @@
1
+ """Allows 'python -m hios_ssh_runner'.
2
+
3
+ Copyright (C) 2026 Pavel Dvořák
4
+ SPDX-License-Identifier: AGPL-3.0-only
5
+ """
6
+
7
+ import sys
8
+
9
+ from hios_ssh_runner.cli import main
10
+
11
+ sys.exit(main())
hios_ssh_runner/cli.py ADDED
@@ -0,0 +1,353 @@
1
+ """
2
+ hios-ssh-runner - run CLI commands on Hirschmann HiOS switches over SSH.
3
+
4
+ Log in over SSH to a list of Hirschmann HiOS switches, enter privileged
5
+ mode with 'enable' (no enable password), run a list of CLI commands and
6
+ save the output of every device to its own log file.
7
+
8
+ devices file : one switch per line -> IP / hostname, optional ":port"
9
+ commands file : one CLI command per line
10
+ username/pass : asked interactively when the tool starts
11
+ (password is read with getpass, never echoed or logged)
12
+
13
+ Typical HiOS session this tool automates:
14
+
15
+ (Hirschmann GRS1042) >enable
16
+ (Hirschmann GRS1042) #cli numlines 0 <- disable paging (optional)
17
+ (Hirschmann GRS1042) #show system info
18
+ ...
19
+ (Hirschmann GRS1042) #logout
20
+
21
+ Security notes (IEC 62443 mindset - secure by default):
22
+ * SSH host keys are VERIFIED against a known_hosts file. Unknown keys are
23
+ rejected unless you explicitly pass --accept-new-hostkeys (trust on first
24
+ use; the new key is then saved so later runs are verified).
25
+ * Credentials live only in memory and are never written to the logs.
26
+ * No SSH agent / local key files are offered to the switch.
27
+
28
+ Requires: Python 3.8+, paramiko
29
+ Reference: paramiko documentation, https://docs.paramiko.org
30
+
31
+ Copyright (C) 2026 Pavel Dvořák
32
+
33
+ This program is free software: you can redistribute it and/or modify
34
+ it under the terms of the GNU Affero General Public License as published
35
+ by the Free Software Foundation, version 3.
36
+
37
+ This program is distributed in the hope that it will be useful,
38
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
39
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
40
+ GNU Affero General Public License for more details.
41
+
42
+ You should have received a copy of the GNU Affero General Public License
43
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
44
+ """
45
+
46
+ import argparse
47
+ import getpass
48
+ import os
49
+ import re
50
+ import sys
51
+ import time
52
+ from datetime import datetime
53
+
54
+ try:
55
+ import paramiko
56
+ except ImportError:
57
+ sys.exit("paramiko is not installed. Install it with: pip install paramiko")
58
+
59
+ from hios_ssh_runner import __version__ as VERSION
60
+
61
+ PROG = "hios-ssh-runner"
62
+
63
+ # Prompt at the end of the buffer: "...>" (user mode) or "...#" (privileged).
64
+ # HiOS example: "(Hirschmann GRS1042) >" / "(Hirschmann GRS1042) #"
65
+ PROMPT_RE = re.compile(r"[>#]\s*$")
66
+ PRIV_PROMPT_RE = re.compile(r"#\s*$")
67
+
68
+ # Paging prompts - if paging is still active we press SPACE to continue.
69
+ MORE_RE = re.compile(r"--More--|Press any key|\(q\)uit", re.IGNORECASE)
70
+
71
+ # Same, for removing the leftover paging prompt text from the saved log.
72
+ MORE_PROMPT_LINE_RE = re.compile(r"--More--( or \(q\)uit)?|Press any key[^\r\n]*",
73
+ re.IGNORECASE)
74
+
75
+ # Questions we must NOT answer automatically (e.g. reboot / overwrite).
76
+ CONFIRM_RE = re.compile(r"\(y/n\)|\[y/n\]|\(yes/no\)", re.IGNORECASE)
77
+
78
+ # ANSI escape sequences some CLIs emit (cursor moves, colours).
79
+ ANSI_RE = re.compile(r"\x1b\[[0-9;?]*[A-Za-z]")
80
+
81
+
82
+ # --------------------------------------------------------------------------
83
+ # Helper functions
84
+ # --------------------------------------------------------------------------
85
+ def read_list_file(path):
86
+ """Return non-empty lines of a text file, ignoring '#' comment lines."""
87
+ if not os.path.isfile(path):
88
+ sys.exit(f"ERROR: file not found: {path}")
89
+ items = []
90
+ with open(path, encoding="utf-8") as fh:
91
+ for line in fh:
92
+ line = line.strip()
93
+ if line and not line.startswith("#"):
94
+ items.append(line)
95
+ if not items:
96
+ sys.exit(f"ERROR: no usable lines in {path}")
97
+ return items
98
+
99
+
100
+ def parse_device(entry, default_port):
101
+ """'10.0.0.1' -> ('10.0.0.1', 22); '10.0.0.1:2222' -> ('10.0.0.1', 2222)."""
102
+ if entry.count(":") == 1: # host:port (plain IPv6 has several colons)
103
+ host, port = entry.split(":")
104
+ return host.strip(), int(port)
105
+ return entry, default_port
106
+
107
+
108
+ def clean(text):
109
+ """Remove ANSI codes, paging prompts and normalise line endings."""
110
+ text = ANSI_RE.sub("", text)
111
+ text = MORE_PROMPT_LINE_RE.sub("", text)
112
+ return text.replace("\r\n", "\n").replace("\r", "")
113
+
114
+
115
+ def read_until_prompt(chan, timeout, prompt_re=PROMPT_RE):
116
+ """
117
+ Read from the interactive shell until the switch shows a prompt.
118
+
119
+ * presses SPACE on '--More--' style paging prompts
120
+ * stops (without answering) on y/n confirmation questions
121
+ * raises TimeoutError if no prompt appears within 'timeout' seconds
122
+ Returns the collected text.
123
+ """
124
+ buf = ""
125
+ deadline = time.time() + timeout
126
+ while time.time() < deadline:
127
+ if chan.recv_ready():
128
+ buf += chan.recv(65535).decode("utf-8", errors="replace")
129
+ deadline = time.time() + timeout # data is flowing -> extend
130
+ raw_tail = ANSI_RE.sub("", buf)[-200:]
131
+ if MORE_RE.search(raw_tail.splitlines()[-1] if raw_tail.splitlines() else ""):
132
+ chan.send(" ")
133
+ continue
134
+ tail = clean(buf)[-200:]
135
+ if CONFIRM_RE.search(tail):
136
+ raise RuntimeError("switch asked for a y/n confirmation - "
137
+ "not answered automatically:\n" + tail)
138
+ if prompt_re.search(tail):
139
+ # short quiet period to be sure the prompt is really the end
140
+ time.sleep(0.2)
141
+ if not chan.recv_ready():
142
+ return clean(buf)
143
+ elif chan.closed or chan.exit_status_ready():
144
+ raise ConnectionError("SSH channel closed by the switch")
145
+ else:
146
+ time.sleep(0.05)
147
+ raise TimeoutError("no prompt received within %ss. Last output:\n%s"
148
+ % (timeout, clean(buf)[-300:]))
149
+
150
+
151
+ def send_command(chan, cmd, timeout):
152
+ """Send one CLI command and return its output (including echo + prompt)."""
153
+ chan.send(cmd + "\n")
154
+ return read_until_prompt(chan, timeout)
155
+
156
+
157
+ # --------------------------------------------------------------------------
158
+ # Per-device work
159
+ # --------------------------------------------------------------------------
160
+ def run_device(host, port, username, password, commands, args):
161
+ """Connect to one switch, run all commands, write the log file."""
162
+ stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
163
+ safe_host = re.sub(r"[^A-Za-z0-9._-]", "_", host)
164
+ port_tag = "" if port == 22 else f"_p{port}"
165
+ log_path = os.path.join(args.output, f"{safe_host}{port_tag}_{stamp}.txt")
166
+
167
+ client = paramiko.SSHClient()
168
+ # Host key handling - verify by default
169
+ if os.path.isfile(args.known_hosts):
170
+ client.load_host_keys(args.known_hosts)
171
+ if args.accept_new_hostkeys:
172
+ client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
173
+ else:
174
+ client.set_missing_host_key_policy(paramiko.RejectPolicy())
175
+
176
+ with open(log_path, "w", encoding="utf-8") as log:
177
+ log.write(f"# Device : {host}:{port}\n# User : {username}\n"
178
+ f"# Time : {stamp}\n# Script : {PROG} v{VERSION}\n\n")
179
+ try:
180
+ # paramiko tries 'password' auth and automatically falls back to
181
+ # 'keyboard-interactive' (often used with RADIUS) if needed.
182
+ client.connect(host, port=port, username=username, password=password,
183
+ timeout=args.connect_timeout,
184
+ banner_timeout=args.connect_timeout,
185
+ auth_timeout=args.connect_timeout,
186
+ look_for_keys=False, allow_agent=False)
187
+ if args.accept_new_hostkeys:
188
+ # store newly accepted keys so the next run is verified
189
+ os.makedirs(os.path.dirname(os.path.abspath(args.known_hosts)),
190
+ exist_ok=True)
191
+ client.save_host_keys(args.known_hosts)
192
+
193
+ chan = client.invoke_shell(term="vt100", width=511, height=1000)
194
+ chan.settimeout(args.timeout)
195
+
196
+ # 1) wait for the login prompt '>'
197
+ log.write(read_until_prompt(chan, args.timeout))
198
+
199
+ # 2) privileged mode - 'enable' has no password on these switches
200
+ out = send_command(chan, "enable", args.timeout)
201
+ log.write(out)
202
+ if not PRIV_PROMPT_RE.search(out):
203
+ raise RuntimeError("'enable' did not give a '#' prompt")
204
+
205
+ # 3) disable paging (optional; '--More--' is handled anyway)
206
+ if args.paging_cmd:
207
+ log.write(send_command(chan, args.paging_cmd, args.timeout))
208
+
209
+ # 4) the user's commands
210
+ for cmd in commands:
211
+ print(f" [{host}] {cmd}")
212
+ log.write(send_command(chan, cmd, args.timeout))
213
+ log.flush()
214
+
215
+ # 5) leave cleanly
216
+ chan.send("logout\n")
217
+ time.sleep(0.5)
218
+ log.write("\n# --- finished OK ---\n")
219
+ return True, log_path
220
+
221
+ except paramiko.BadHostKeyException:
222
+ msg = ("HOST KEY MISMATCH - the key differs from known_hosts. "
223
+ "Possible man-in-the-middle; NOT connecting.")
224
+ except paramiko.AuthenticationException:
225
+ msg = "authentication failed (wrong user/password or RADIUS reject)"
226
+ except paramiko.SSHException as exc:
227
+ if "not found in known_hosts" in str(exc):
228
+ msg = (f"unknown host key ({exc}). Verify the switch fingerprint, "
229
+ "then rerun with --accept-new-hostkeys.")
230
+ else:
231
+ msg = f"SSH error: {exc}"
232
+ except Exception as exc: # timeouts, socket errors, enable failure ...
233
+ msg = f"{type(exc).__name__}: {exc}"
234
+ finally:
235
+ client.close()
236
+
237
+ log.write(f"\n# --- FAILED: {msg} ---\n")
238
+ return False, msg
239
+
240
+
241
+ # --------------------------------------------------------------------------
242
+ # Main
243
+ # --------------------------------------------------------------------------
244
+ def build_parser():
245
+ p = argparse.ArgumentParser(
246
+ prog=PROG,
247
+ formatter_class=argparse.RawDescriptionHelpFormatter,
248
+ description=(
249
+ "Run CLI commands on Hirschmann HiOS switches over SSH.\n\n"
250
+ "Logs in to every switch in the devices file, runs 'enable'\n"
251
+ "(no password), optionally disables paging, executes every\n"
252
+ "line of the commands file and saves one log per device.\n"
253
+ "Username and password are asked for at start-up."),
254
+ epilog=(
255
+ "FILE FORMATS\n"
256
+ " devices.txt one switch per line, optional :port\n"
257
+ " 10.10.10.11\n"
258
+ " grs1042-lab.local:2222\n"
259
+ " commands.txt one command per line (run in '#' mode)\n"
260
+ " show system info\n"
261
+ " show vlan brief\n"
262
+ " Empty lines and lines starting with '#' are ignored.\n\n"
263
+ "EXAMPLES\n"
264
+ " First run (switch keys not yet known - check fingerprints!):\n"
265
+ " hios-ssh-runner -d devices.txt -c commands.txt "
266
+ "--accept-new-hostkeys\n"
267
+ " Normal run (keys verified):\n"
268
+ " hios-ssh-runner -d devices.txt -c commands.txt\n"
269
+ " Without paging command, longer timeout:\n"
270
+ " hios-ssh-runner -d devices.txt -c commands.txt "
271
+ "--paging-cmd \"\" -t 60\n\n"
272
+ "EXIT CODES\n"
273
+ " 0 all devices OK, 1 at least one device failed, 2 aborted by user\n\n"
274
+ "NOTES\n"
275
+ " * Commands that ask (y/n) are NOT confirmed automatically; the\n"
276
+ " device is marked failed so nothing destructive happens silently.\n"
277
+ " * Also runnable as: python -m hios_ssh_runner ...\n"
278
+ " * Config commands work too: put 'configure' ... 'exit' in the\n"
279
+ " commands file. Remember 'save' if changes must survive reboot.\n\n"
280
+ "LICENSE\n"
281
+ " Copyright (C) 2026 Pavel Dvořák. GNU AGPL v3, NO WARRANTY.\n"
282
+ " Hirschmann and HiOS are trademarks of their respective owner;\n"
283
+ " this project is not affiliated with or endorsed by them."))
284
+ p.add_argument("-d", "--devices", default="devices.txt",
285
+ help="file with switch IPs/hostnames (default: devices.txt)")
286
+ p.add_argument("-c", "--commands", default="commands.txt",
287
+ help="file with CLI commands (default: commands.txt)")
288
+ p.add_argument("-o", "--output", default="output",
289
+ help="folder for per-device logs (default: ./output)")
290
+ p.add_argument("-p", "--port", type=int, default=22,
291
+ help="default SSH port if not given in devices file (default: 22)")
292
+ p.add_argument("-t", "--timeout", type=float, default=30,
293
+ help="seconds to wait for a prompt after a command (default: 30)")
294
+ p.add_argument("--connect-timeout", type=float, default=15,
295
+ help="seconds for TCP/SSH/auth handshake (default: 15)")
296
+ p.add_argument("--paging-cmd", default="cli numlines 0",
297
+ help="command sent after enable to disable paging; "
298
+ "use \"\" to skip (default: 'cli numlines 0')")
299
+ p.add_argument("--known-hosts",
300
+ default=os.path.join(os.path.expanduser("~"), ".ssh", "known_hosts"),
301
+ help="known_hosts file for host key verification "
302
+ "(default: ~/.ssh/known_hosts)")
303
+ p.add_argument("--accept-new-hostkeys", action="store_true",
304
+ help="trust and save UNKNOWN host keys (first run only). "
305
+ "Changed keys are still rejected.")
306
+ p.add_argument("-u", "--username",
307
+ help="username (if omitted you are asked at start-up)")
308
+ p.add_argument("-V", "--version", action="version", version=f"%(prog)s {VERSION}")
309
+ return p
310
+
311
+
312
+ def main():
313
+ args = build_parser().parse_args()
314
+
315
+ devices = [parse_device(d, args.port) for d in read_list_file(args.devices)]
316
+ commands = read_list_file(args.commands)
317
+ os.makedirs(args.output, exist_ok=True)
318
+
319
+ print(f"{len(devices)} device(s), {len(commands)} command(s)")
320
+ try:
321
+ username = args.username or input("Username: ").strip()
322
+ password = getpass.getpass("Password: ")
323
+ except (KeyboardInterrupt, EOFError):
324
+ print("\nAborted.")
325
+ return 2
326
+ if not username or not password:
327
+ print("Username and password are required.")
328
+ return 2
329
+
330
+ results = []
331
+ try:
332
+ for host, port in devices:
333
+ print(f"\n=== {host}:{port} ===")
334
+ ok, info = run_device(host, port, username, password, commands, args)
335
+ print(f" -> {'OK, log: ' if ok else 'FAILED: '}{info}")
336
+ results.append((f"{host}:{port}", ok, info))
337
+ except KeyboardInterrupt:
338
+ print("\nAborted by user.")
339
+ return 2
340
+ finally:
341
+ password = None # drop the reference as soon as we are done
342
+
343
+ print("\n=== SUMMARY ===")
344
+ for host, ok, info in results:
345
+ print(f" {'OK ' if ok else 'FAILED'} {host}")
346
+ failed = sum(1 for _, ok, _ in results if not ok)
347
+ print(f"{len(results) - failed} OK, {failed} failed. Logs in: "
348
+ f"{os.path.abspath(args.output)}")
349
+ return 1 if failed else 0
350
+
351
+
352
+ if __name__ == "__main__":
353
+ sys.exit(main())