-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathpush_config.py
More file actions
1315 lines (1148 loc) · 54.8 KB
/
Copy pathpush_config.py
File metadata and controls
1315 lines (1148 loc) · 54.8 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
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""
Batch configuration push script for Cisco IOS / Catalyst 1300 / H3C devices.
Usage examples:
python push_config.py # default device.xlsx
python push_config.py --excel device.xlsx --workers 10
python push_config.py --dry-run # validate only: connect + detect platform
python push_config.py --only 192.168.30.100 # process one device only
python push_config.py --no-retry # disable second-pass retry
Design notes:
1. ICMP reachability check with ping for quick online/offline validation.
2. Users provide 5 Excel input columns (Device Name/Management IP/Vendor/Model/Platform).
The script writes back 4 result columns: Status / Reason / Log Path / Finished At.
Also, if "Platform" was empty or invalid and platform detection succeeds this run,
the detected value is written back to "Platform" (fill-empty only, never overwrite a
valid user-provided value). Other user input columns are never modified.
3. Platform detection trusts Excel "Platform" first. If empty/invalid, the script logs in
and falls back to automatic detection via show/display version. The detected platform is
logged and written back to Excel "Platform" (as described above).
Recommended flow: run `--dry-run` first to validate connectivity and platform detection
before applying real configuration changes.
4. Configuration templates are strict one-to-one by model:
configs/by_model/<Model>.txt (also accepts .cfg/.conf).
No fallback is used. Missing template -> mark device as NO_TEMPLATE and skip push to
avoid applying incorrect commands.
5. Command execution uses send_command_timing(last_read=1.0) per line instead of
send_config_set. last_read=1.0 favors stability and full command output capture,
reducing premature reads when devices return output in chunks. It is still several
times faster than the old send_config_set approach (~4.4s/line).
6. Multithreaded execution; one device failure does not affect others.
7. Dual-track logging:
- Per-device .log file (full command interaction)
- logs/run_xxx/summary.log (global summary)
8. Interactive commands like crypto key generate rsa / snmp-server engineid are auto-
answered with Y/yes.
9. Failed devices are automatically retried once after the first run.
"""
from __future__ import annotations
import argparse
import getpass
import logging
import platform
import re
import subprocess
import sys
import threading
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime
from pathlib import Path
from typing import Optional
from openpyxl import load_workbook
try:
from netmiko import ConnectHandler
from netmiko.exceptions import (
NetMikoAuthenticationException,
NetMikoTimeoutException,
ReadTimeout as NetmikoReadTimeout,
)
try:
from paramiko.ssh_exception import SSHException
except Exception: # pragma: no cover
SSHException = Exception # type: ignore
except ImportError:
print("[!] Missing dependencies. Please run: pip install -r requirements.txt")
sys.exit(1)
# ============================ C1300 safe handshake patch ============================
# Background:
# In netmiko 4.6.0, the cisco_s300 driver sends two commands immediately after SSH login:
# terminal width 511
# terminal datadump
# With `aaa authorization commands ... tacacs` in C1300 startup-config, both commands
# trigger TACACS command-authorization round trips. Firmware 4.1.7.17 / 4.1.7.26 has
# race/state-machine defects in the AAA command-authorization path. When triggered,
# devices may panic + cold reset.
# (4.1.7.17 leaves %AAA-F-USRSTERR fatal traces in buffer; 4.1.7.26 fixed that fatal
# path but not the root crash point, so failures can appear more silent.)
#
# Mitigation:
# Override CiscoS300Base.session_preparation to skip these two risky commands and keep
# only minimal handshake (read prompt -> record base_prompt). Trade-offs:
# - Long output may wrap around ~80 chars (no impact; script does not parse long output)
# - Commands like show running-config may paginate with 'More:' (no impact; script sends
# one command at a time and reads one response)
# Benefit: fully removes reboot risk triggered during SSH session initialization.
try:
from netmiko.cisco.cisco_s300 import CiscoS300Base as _CiscoS300Base
def _c1300_safe_session_preparation(self) -> None:
self.ansi_escape_codes = True
output = self._test_channel_read(pattern=r"[>#]")
prompt = (output or "").strip().splitlines()[-1].strip() if output else ""
if prompt:
self._c1300_initial_prompt = prompt
self.base_prompt = re.sub(r"[>#]\s*$", "", prompt)
else:
self.base_prompt = ""
# Intentionally do not call set_base_prompt / set_terminal_width / disable_paging.
# set_base_prompt() sends an extra Enter key. For C1300 + TACACS command accounting,
# SSH initialization must remain "read prompt only, no command/Enter sent".
def _c1300_safe_cleanup(self, command: str = "exit") -> None:
# netmiko default cleanup() sends Enter to check config mode, then sends exit.
# On C1300, exit also goes through AAA command accounting and is a high-risk crash
# trigger. Here we send no CLI characters at all and only close the SSH transport.
if self.session_log:
self.session_log.fin = True
_CiscoS300Base.session_preparation = _c1300_safe_session_preparation
_CiscoS300Base.cleanup = _c1300_safe_cleanup
except Exception as _e: # pragma: no cover
print(f"[!] Warning: failed to patch cisco_s300 driver; C1300 may still reboot: {_e}")
# ============================ Status codes ============================
ST_SUCCESS = "SUCCESS"
ST_PING_FAIL = "PING_FAIL"
ST_AUTH_FAIL = "SSH_AUTH_FAIL"
ST_CONN_FAIL = "SSH_CONN_FAIL"
ST_DETECT_FAIL = "DETECT_FAIL"
ST_NO_TEMPLATE = "NO_TEMPLATE"
ST_CMD_ERROR = "CMD_ERROR"
ST_UNKNOWN = "UNKNOWN"
ST_DRY_OK = "DRY_RUN_OK"
# Failures worth retrying once:
# - PING_FAIL / SSH_CONN_FAIL: transient network jitter often recovers
# - DETECT_FAIL: rare race condition may succeed on retry
# - UNKNOWN: unknown error gets one retry chance
#
# Excluded from retry:
# - SSH_AUTH_FAIL: credentials are wrong; retry may accelerate account lockout
# - CMD_ERROR: device connected and config may be partially applied (including AAA changes).
# Auto-retry may become SSH_AUTH_FAIL if new auth path is unreachable; verify manually and
# rerun single device with --only.
# - NO_TEMPLATE: template is missing; retry yields same result until template is added.
RETRY_STATUSES = {
ST_PING_FAIL, ST_CONN_FAIL,
ST_DETECT_FAIL, ST_UNKNOWN,
}
# ============================ Platform mapping ============================
# Supported platform whitelist (used only to validate Excel "Platform" and select SSH driver)
VALID_PLATFORMS = {"cisco_ios", "cisco_xe", "cisco_s300", "hp_comware"}
# Known vendor aliases (for logs/debug only; does not affect login)
VENDOR_ALIASES = {
"cisco": "cisco", "思科": "cisco",
"h3c": "h3c", "新华三": "h3c",
"huawei": "huawei", "华为": "huawei",
}
# Used to scan command output for errors
ERROR_PATTERNS = re.compile(
r"(% Invalid input|% Ambiguous|% Incomplete command|% Unknown command|"
r"% Error|% Unrecognized|^Error:|Wrong parameter|Too many parameters|"
r"Unrecognized command|Permission denied|Bad command|Invalid command|"
r"bad parameter value|Wrong number of parameters)",
re.IGNORECASE | re.MULTILINE,
)
# Commands containing these keywords require special handling: execute from privileged exec
# and use a longer read_timeout (e.g. RSA key generation). Note: interactive prompt auto-answer
# now applies to all commands globally (see _interactive_answer / _answer_prompts), so behavior
# no longer depends on this keyword whitelist.
INTERACTIVE_KEYWORDS = ("crypto key generate rsa", "snmp-server engineid local")
# Save commands: devices prompt for confirmation and must be auto-answered
# (see send_interactive). For Cisco (IOS / Small Business C1300), save commands must run in
# privileged exec, so the script exits config mode first. Covers:
# copy run start / copy running-config startup-config /
# wr / write / write memory / save / save force (H3C)
SAVE_CMD_RE = re.compile(
r"^\s*(?:"
r"copy\s+run\S*\s+star\S*|" # copy run start / copy running-config startup-config
r"wr(?:ite)?(?:\s+mem\S*)?|" # wr / write / write mem / write memory
r"save(?:\s+\S+)?" # save / save force(H3C)
r")\s*$",
re.IGNORECASE,
)
excel_lock = threading.Lock()
# ============================ Utility functions ============================
def cell_str(ws, row: int, col: int) -> str:
v = ws.cell(row, col).value
return str(v).strip() if v is not None else ""
def ping_host(ip: str, count: int = 2, timeout_ms: int = 1500) -> bool:
"""Cross-platform ping reachability check.
Sends 2 packets with 1.5s timeout each; host is considered reachable if any packet succeeds.
A single 600ms packet timeout is not reliable on real networks: initial ARP resolution,
route establishment, and Windows ping process cold start can push latency to hundreds of ms
or occasional timeout. Even devices with ~10ms latency may still show PING_FAIL in scripts,
so settings are relaxed to 2 packets / 1.5s.
Windows: ping return code 0 = at least one packet succeeded; failure returns 1.
Linux : same behavior.
"""
is_windows = platform.system().lower().startswith("win")
if is_windows:
cmd = ["ping", "-n", str(count), "-w", str(timeout_ms), ip]
else:
cmd = ["ping", "-c", str(count), "-W", str(max(1, timeout_ms // 1000)), ip]
try:
r = subprocess.run(
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
timeout=count * (timeout_ms / 1000) + 3,
)
return r.returncode == 0
except Exception:
return False
def normalize_model(model: str) -> str:
"""Normalize device model string: trim and collapse internal whitespace.
Used for template filename generation only. **No fuzzy matching or suffix stripping**.
Example: ' WS-C2960X-48TS-L ' -> 'WS-C2960X-48TS-L'
"""
return re.sub(r"\s+", "", (model or "").strip())
def resolve_config_file(model: str, configs_dir: Path) -> Path:
"""Find configuration template by strict "model -> filename" mapping.
Rule:
configs/by_model/<Model>.txt (also accepts .cfg / .conf)
Raises FileNotFoundError if not found. Caller must skip the device and log the reason.
**No fallback matching** to avoid applying the wrong command set.
"""
m = normalize_model(model)
if not m:
raise ValueError("Missing 'Model' value in Excel; cannot locate configuration template")
by_model_dir = configs_dir / "by_model"
for ext in (".txt", ".cfg", ".conf"):
fp = by_model_dir / f"{m}{ext}"
if fp.exists():
return fp
raise FileNotFoundError(
f"Template not found for model '{m}'. Expected path: "
f"{(by_model_dir / (m + '.txt')).as_posix()}"
)
def detect_hostname(conn) -> str:
"""Extract hostname from current session prompt across vendor prompt styles:
Cisco IOS / IOS-XE / C1300: hostname# or hostname>
H3C user view : <hostname>
H3C system view : [hostname]
Cisco config sub-mode : hostname(config)# -> returns 'hostname'
"""
try:
prompt = conn.find_prompt() or ""
# Extract first valid hostname token (letters/digits/underscore/hyphen/dot)
m = re.search(r"[A-Za-z0-9][\w\-.]*", prompt)
return m.group(0) if m else ""
except Exception:
return ""
def detect_platform(conn) -> Optional[str]:
"""Detect vendor platform on an established session and return netmiko device_type.
Uses send_command_timing instead of send_command to avoid prompt-matching dependency
(otherwise cisco_ios driver connected to H3C may hang on paging).
"""
# 1. Best-effort disable paging to avoid H3C ---- More ---- hangs
for paging_off in ("terminal length 0", "screen-length disable"):
try:
conn.send_command_timing(
paging_off, last_read=1.0, read_timeout=5,
strip_command=False, strip_prompt=False,
)
except Exception:
pass
def _send(cmd: str) -> str:
try:
return conn.send_command_timing(
cmd, last_read=2.0, read_timeout=15,
strip_command=False, strip_prompt=False,
) or ""
except Exception:
return ""
# 2. Try Cisco-style command first (show version), then H3C-style (display version).
# For either command, scan all platform keywords because some devices accept aliases.
for cmd in ("show version", "display version"):
out = _send(cmd)
if not out:
continue
low = out.lower()
# H3C: check first to avoid misclassification from terms like "ios xe"
if "h3c comware" in low or "comware software" in low \
or "new h3c" in low or "h3c s" in low:
return "hp_comware"
# Cisco 1300 / CBS
if "cisco small business" in low or "cbs350" in low or "cbs250" in low \
or "catalyst 1300" in low or "1300 series" in low:
return "cisco_s300"
# IOS-XE
if "ios-xe" in low or "ios xe" in low:
return "cisco_xe"
# Standard IOS (including Catalyst 1000 / C1000 running standard IOS)
if "cisco ios" in low or "catalyst 1000" in low or "c1000" in low:
return "cisco_ios"
return None
def make_device_logger(ip: str, log_dir: Path) -> logging.Logger:
logger = logging.getLogger(f"dev.{ip}")
if logger.handlers:
return logger
logger.setLevel(logging.DEBUG)
logger.propagate = False
fh = logging.FileHandler(log_dir / f"{ip}.log", encoding="utf-8")
fh.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s"))
logger.addHandler(fh)
return logger
def load_config_lines(model: str, platform_type: str, hostname: str,
configs_dir: Path) -> tuple:
"""Locate configs/by_model/<Model>.txt, load it, and replace hostname/sysname.
Returns (lines, used_template_path).
Raises on missing model or missing template; caller should skip that device.
"""
fp = resolve_config_file(model, configs_dir)
text = fp.read_text(encoding="utf-8")
if platform_type == "hp_comware":
text = re.sub(r"^\s*sysname\s+\S+",
f"sysname {hostname}", text, count=1, flags=re.MULTILINE)
else:
text = re.sub(r"^\s*hostname\s+\S+",
f"hostname {hostname}", text, count=1, flags=re.MULTILINE)
lines = []
for raw in text.splitlines():
s = raw.rstrip()
if not s.strip():
continue
# Remove trailing // inline comments
s = re.sub(r"\s*//.*$", "", s)
if not s.strip():
continue
# Skip pure comment lines (! or #), preventing control characters in comment bytes
# from affecting device sessions (safe for IOS/H3C; H3C system-view has no valid
# commands that start with #).
ls = s.lstrip()
if ls.startswith("!") or ls.startswith("#"):
continue
lines.append(s)
return lines, fp
def is_interactive(line: str) -> bool:
low = line.strip().lower()
return any(k in low for k in INTERACTIVE_KEYWORDS)
def is_save_command(line: str) -> bool:
"""Whether this is a save command (copy run start / write memory / save ...)."""
return bool(SAVE_CMD_RE.match(line or ""))
# -- Interactive prompt detection (based on last output line to avoid false positives) --
# If output already returns to normal command prompt (#/> and not a question), command is done
_PROMPT_DONE_RE = re.compile(r"[>#]\s*$")
_MODULUS_RE = re.compile(r"how many bits in the modulus", re.IGNORECASE)
# Prompts requiring Enter (accept default): destination filename / file name / press enter key
_FILENAME_RE = re.compile(r"destination filename|file name|enter key", re.IGNORECASE)
# Prompts requiring 'yes': [yes/no] / (yes/no)
_YESNO_RE = re.compile(r"\[yes/no\]|\(yes/no\)|\byes/no\b", re.IGNORECASE)
# Prompts requiring 'Y': [Y/N] (Y/N) y/n [confirm] overwrite file, or any confirmation ending with '?'
_YN_RE = re.compile(
r"\[y/n\]|\(y/n\)|\by/n\b|\[confirm\]|overwrite\s+file|\?\s*$",
re.IGNORECASE,
)
def _tail_line(text: str) -> str:
"""Get the last non-empty line from output (prompt line when device waits for input)."""
for ln in (text or "").splitlines()[::-1]:
if ln.strip():
return ln.strip()
return ""
def _interactive_answer(recent: str) -> Optional[str]:
"""Detect whether device is waiting on an interactive prompt and return response.
Returns None when no response is needed.
Detection uses only the last output line and covers common prompts
(ordered from most specific to least specific):
- Back to #/> prompt (non-question) -> None (command done)
- RSA modulus 'How many bits in the modulus [512]:' -> 2048
- Destination filename:
IOS 'Destination filename [startup-config]?'
H3C 'press the enter key to specify the file name' -> Enter (accept default)
- yes/no style '... ? [yes/no]:' (e.g. RSA overwrite confirmation) -> yes
- Y/N confirmations:
'Overwrite file [...] (Y/N)[N] ?' / '[confirm]' / any '?'-ended question -> Y
"""
tail = _tail_line(recent)
if not tail:
return None
# Last line is normal command prompt (hostname# / <hostname> etc.), command is done
if _PROMPT_DONE_RE.search(tail) and not tail.endswith("?"):
return None
if _MODULUS_RE.search(tail):
return "2048"
if _FILENAME_RE.search(tail):
return "" # Enter only, accept default filename
if _YESNO_RE.search(tail):
return "yes"
if _YN_RE.search(tail):
return "Y"
return None
def _answer_prompts(conn, full: str, dev_logger: logging.Logger,
max_rounds: int = 6) -> str:
"""Auto-answer all follow-up y/yes/confirm/filename prompts for one command output.
Each round checks only the most recent output to decide whether another response is needed.
Stops immediately when device no longer prompts, preventing extra yes/Y from being sent
as unintended commands at the next prompt.
"""
recent = full
for _ in range(max_rounds):
answer = _interactive_answer(recent)
if answer is None:
break
shown = answer if answer else "<Enter>"
dev_logger.info(f"[Interactive] Detected prompt, auto-answering: {shown}")
try:
recent = conn.send_command_timing(
answer, last_read=1.0, read_timeout=60,
strip_prompt=False, strip_command=False,
) or ""
except Exception:
recent = ""
full += recent
return full
def send_interactive(conn, line: str, dev_logger: logging.Logger) -> str:
"""Send one interactive command and automatically answer confirmation prompts.
Note: interactive commands (e.g. crypto key generate rsa) may take several seconds.
Therefore last_read is slightly larger (1.0s) than normal command handling to ensure
full prompt text capture.
"""
full = conn.send_command_timing(
line, last_read=1.0, read_timeout=60,
strip_prompt=False, strip_command=False,
) or ""
full = _answer_prompts(conn, full, dev_logger)
dev_logger.info(full)
return full
def _enter_config_mode(conn, dev_logger: logging.Logger) -> bool:
"""Enter configuration mode uniformly: Cisco -> configure terminal; H3C -> system-view.
Uses send_command_timing manually for better robustness than netmiko.config_mode()
(avoids false "Failed to enter configuration mode" on user mode or customized prompts).
ERROR_PATTERNS in output means failure; config-mode markers mean success; otherwise logs
a warning and continues (most cases are successful entry with customized prompt).
"""
if conn.device_type == "hp_comware":
cmd, ok_marks = "system-view", ("[",)
else:
cmd, ok_marks = "configure terminal", ("(config",)
try:
out = conn.send_command_timing(
cmd, last_read=1.0, read_timeout=10,
strip_command=False, strip_prompt=False,
) or ""
except Exception as e:
dev_logger.error(f"[ConfigMode] Exception sending '{cmd}': {e}")
return False
dev_logger.info(f">>> {cmd}")
if out.strip():
dev_logger.info(out)
if ERROR_PATTERNS.search(out):
dev_logger.error(f"[ConfigMode] Device rejected config mode entry: {out.strip()[:200]}")
return False
if not any(m in out for m in ok_marks):
dev_logger.warning(
f"[ConfigMode] Config mode markers {ok_marks} not found in output; continuing anyway"
)
return True
def _exit_config_mode(conn, dev_logger: logging.Logger) -> None:
"""Exit to user/exec view before session close.
- H3C: use 'return' (single-step from any sub-view to user view; avoids extra quit
dropping session toward exit state when template already includes quit)
- Cisco: use 'end' (single-step from any config sub-mode to privileged exec)
"""
cmd = "return" if conn.device_type == "hp_comware" else "end"
try:
conn.send_command_timing(
cmd, last_read=1.0, read_timeout=10,
strip_command=False, strip_prompt=False,
)
except Exception:
pass
def _scan_error(output: str, cmd: str) -> str:
"""Extract error snippet from device output; return empty string when no error."""
if not output or not ERROR_PATTERNS.search(output):
return ""
last = ""
for ln in output.strip().splitlines()[::-1]:
if ln.strip():
last = ln.strip()
break
return f"'{cmd}' -> {last[:120]}"
def push_config(conn, lines: list, platform_type: str,
dev_logger: logging.Logger) -> list:
"""Push configuration line by line and return list of command errors (non-blocking).
Performance notes:
- Uses send_command_timing(last_read=1.0) per line instead of send_config_set:
avoids ~3s fixed overhead from repeated find_prompt / command-echo verification.
- last_read=1.0 means command is considered complete after 1.0s idle output. This
stability-first setting ensures full error keyword capture (e.g. 'Wrong parameter' /
'% Invalid input') and avoids premature reads on chunked outputs.
- Slow commands (RSA/SNMP user rebuild etc.) go through interactive or longer-timeout path.
"""
errors: list = []
if not _enter_config_mode(conn, dev_logger):
errors.append("config_mode: failed to enter configuration mode")
return errors
in_config = True # Track current mode to decide whether end/return is needed at teardown
for idx, line in enumerate(lines):
stripped = line.strip()
if not stripped:
continue
# Whether there are real commands after this one (save/interactive often appears last)
has_more = any(l.strip() for l in lines[idx + 1:])
try:
save_cmd = is_save_command(stripped)
if is_interactive(stripped) or save_cmd:
low = stripped.lower()
dev_logger.info(
f">>> {stripped} [{'save' if save_cmd else 'interactive'}]"
)
# Only save commands need privileged exec mode (copy/write are exec commands
# on IOS/C1300). H3C save can run in any view and does not require exit.
#
# Note: crypto key generate rsa is a config-mode command on both Cisco IOS
# and C1300(cisco_s300). Earlier versions wrongly moved it to privileged exec,
# causing "% Unrecognized command" on C1300. That prevents execution and no
# (Y/N) prompt appears. Keep it in config mode for proper prompt + auto-answer.
need_exit = (conn.device_type != "hp_comware" and save_cmd)
if need_exit:
_exit_config_mode(conn, dev_logger)
in_config = False
iout = send_interactive(conn, stripped, dev_logger)
err = _scan_error(iout, stripped)
if err:
errors.append(err)
dev_logger.warning(f"[CMD_ERROR] {err}")
# Re-enter config mode only if there are remaining config commands.
# Otherwise it is unnecessary and may be interrupted by async C1300
# %COPY-N-TRAP logs after copy completes, polluting configure terminal output
# and causing false CMD_ERROR detections.
if need_exit and has_more:
if _enter_config_mode(conn, dev_logger):
in_config = True
else:
dev_logger.warning("[ConfigMode] Failed to re-enter config mode after interactive command")
continue
output = conn.send_command_timing(
stripped, last_read=1.0, read_timeout=15,
strip_command=False, strip_prompt=False,
) or ""
# If normal commands unexpectedly trigger y/yes/confirm prompts, auto-answer too
# (requirement: always answer y/yes prompts automatically when they appear).
if _interactive_answer(output) is not None:
output = _answer_prompts(conn, output, dev_logger)
dev_logger.info(f">>> {stripped}")
if output.strip():
dev_logger.info(output)
err = _scan_error(output, stripped)
if err:
errors.append(err)
dev_logger.warning(f"[CMD_ERROR] {err}")
except Exception as e:
errors.append(f"'{stripped}' -> exception: {e}")
dev_logger.error(f"[EXCEPTION] '{stripped}' -> {e}")
# Try to recover config mode so remaining commands can continue
in_config = _enter_config_mode(conn, dev_logger)
# Exit only when still in config mode. If already in exec after save/interactive commands,
# skip extra end to avoid waiting on async C1300 TRAP logs again.
if in_config:
_exit_config_mode(conn, dev_logger)
return errors
# ============================ Single-device processing ============================
def _connect(device_type: str, ip: str, username: str, password: str,
secret: str = ""):
return ConnectHandler(
device_type=device_type,
host=ip,
username=username,
password=password,
secret=secret, # enable password (Cisco); empty string means no enable password
fast_cli=False,
conn_timeout=15,
auth_timeout=30, # allow TACACS probe delay + SSH handshake time
banner_timeout=30,
session_timeout=120, # overall session timeout for slow devices
read_timeout_override=60, # read timeout for initialization commands like set_terminal_width
)
def ensure_enable_mode(conn, password: str, dev_logger: logging.Logger,
enable_password: Optional[str]) -> bool:
"""Actively enter privileged mode on Cisco platforms. Returns success status.
H3C has no enable concept and is skipped.
Uses find_prompt instead of only netmiko check_enable_mode, because the latter may
mis-detect on some cisco_xe / C1300 prompts (looks like # but actually customized user mode),
leading to % Invalid input on subsequent configure terminal.
"""
if conn.device_type == "hp_comware":
return True
# C1300 safe handshake patch caches the first prompt seen after login.
# If already in privileged exec (#), avoid extra Enter probe in dry-run stage.
initial_prompt = getattr(conn, "_c1300_initial_prompt", "")
if conn.device_type == "cisco_s300" and initial_prompt.endswith("#"):
dev_logger.info(f"[Enable] Already in privileged mode (prompt={initial_prompt!r})")
return True
try:
prompt = conn.find_prompt() or ""
except Exception:
prompt = ""
in_priv = prompt.endswith("#") and "(config" not in prompt
if in_priv:
dev_logger.info(f"[Enable] Already in privileged mode (prompt={prompt!r})")
return True
if prompt:
dev_logger.info(f"[Enable] Current prompt={prompt!r}; attempting privileged mode")
# Attempt order: (1) user-specified enable password (2) empty password
# (3) login password (many devices reuse login password for enable)
candidates = []
if enable_password is not None:
candidates.append(enable_password)
else:
candidates.append("") # empty enable
if password:
candidates.append(password) # reuse login password
last_err: Optional[Exception] = None
for secret_try in candidates:
try:
conn.secret = secret_try
conn.enable()
try:
new_prompt = conn.find_prompt() or ""
except Exception:
new_prompt = ""
if new_prompt.endswith("#") and "(config" not in new_prompt:
tag = ("empty" if secret_try == ""
else ("login password" if secret_try == password else "specified password"))
dev_logger.info(
f"[Enable] Entered privileged mode (using {tag} for enable, prompt={new_prompt!r})"
)
return True
except Exception as e:
last_err = e
dev_logger.warning(f"[Enable] Attempt failed: {type(e).__name__}: {e}")
dev_logger.error(f"[Enable] All attempts failed; last error: {last_err}")
return False
def process_device(row: dict, username: str, password: str,
configs_dir: Path, log_dir: Path,
dry_run: bool,
enable_password: Optional[str] = None) -> dict:
"""Process one device. Never raises outward; always returns a result dict."""
ip = row["ip"]
name = (row.get("name") or "").strip()
vendor = (row.get("vendor") or "").strip()
model = (row.get("model") or "").strip()
declared_raw = (row.get("platform") or "").strip().lower()
declared = declared_raw if declared_raw in VALID_PLATFORMS else None
result = {
"row_idx": row["row_idx"],
"ip": ip,
"name": name or ip,
"vendor": vendor,
"model": model,
"declared_platform": declared_raw, # for logging only
"used_platform": "", # actual login platform (including fallback detection)
"status": ST_UNKNOWN,
"reason": "",
"log_path": str((log_dir / f"{ip}.log").as_posix()),
"finished_at": "",
}
dev_logger = make_device_logger(ip, log_dir)
dev_logger.info(
f"========== Start processing {name or ip} ({ip}) "
f"vendor={vendor or '-'} model={model or '-'} "
f"platform={declared_raw or '-'} =========="
)
if declared_raw and declared is None:
dev_logger.warning(
f"[Excel] Platform value '{declared_raw}' is not in whitelist {sorted(VALID_PLATFORMS)}; "
f"fallback auto-detection will be used"
)
conn = None
try:
# 1) Ping
dev_logger.info(f"[Ping] Checking reachability {ip}")
if not ping_host(ip):
dev_logger.error("[Ping] Unreachable")
result["status"] = ST_PING_FAIL
result["reason"] = "ICMP reachability check failed (packet timeout)"
return result
dev_logger.info("[Ping] Reachable")
# 2) SSH login: trust declared Excel platform; use fallback detection only when empty/invalid
if declared:
dev_logger.info(f"[SSH] Logging in using Excel-declared platform {declared}")
try:
conn = _connect(declared, ip, username, password)
except NetMikoAuthenticationException as e:
result["status"] = ST_AUTH_FAIL
result["reason"] = "SSH authentication failed (invalid username/password)"
dev_logger.error(f"[SSH] Authentication failed: {e}")
return result
except (NetmikoReadTimeout, NetMikoTimeoutException, SSHException, OSError) as e:
result["status"] = ST_CONN_FAIL
result["reason"] = (
"SSH session initialization timed out (slow AAA exec authorization; verify TACACS server reachability)"
if "terminal width" in str(e) else f"SSH connection failed: {_shorten_exc(e)}"
)
dev_logger.error(f"[SSH] Connection/initialization failed: {_shorten_exc(e)}")
return result
platform_type = declared
else:
dev_logger.info(
"[SSH] Platform not declared (or invalid); trying cisco_ios / hp_comware fallback login + detection"
)
detected: Optional[str] = None
last_err: Exception = Exception("No driver attempted")
connect_ok_at_least_once = False
for fallback_type in ("cisco_ios", "hp_comware"):
test_conn = None
try:
test_conn = _connect(fallback_type, ip, username, password)
except NetMikoAuthenticationException as e:
result["status"] = ST_AUTH_FAIL
result["reason"] = "SSH authentication failed (invalid username/password)"
dev_logger.error(f"[SSH] Authentication failed: {e}")
return result
except (NetmikoReadTimeout, NetMikoTimeoutException, SSHException, OSError) as e:
dev_logger.warning(
f"[SSH] Login failed with {fallback_type} driver: {type(e).__name__} {e}"
)
last_err = e
continue
connect_ok_at_least_once = True
dev_logger.info(f"[SSH] Logged in with {fallback_type} driver; attempting platform detection...")
try:
detected = detect_platform(test_conn)
except Exception as e:
dev_logger.warning(f"[Detect] Detection exception under {fallback_type} driver: {e}")
detected = None
if detected:
dev_logger.info(f"[Detect] Auto-detected platform: {detected}")
conn = test_conn
break
dev_logger.info(
f"[Detect] Detection unsuccessful under {fallback_type}; switching to next driver"
)
try:
test_conn.disconnect()
except Exception:
pass
if conn is None:
if connect_ok_at_least_once:
result["status"] = ST_DETECT_FAIL
result["reason"] = (
"Fallback login succeeded but platform auto-detection failed. Fill correct value in Excel Platform column and retry."
)
dev_logger.error("[Detect] Platform detection failed under all fallback drivers")
else:
result["status"] = ST_CONN_FAIL
result["reason"] = (
"SSH session initialization timed out (slow AAA exec authorization; verify TACACS server reachability)"
if "terminal width" in str(last_err) else f"SSH connection failed: {_shorten_exc(last_err)}"
)
dev_logger.error(f"[SSH] All drivers failed; last error: {_shorten_exc(last_err)}")
return result
# Detected platform differs from current driver -> reconnect with correct driver
if conn.device_type != detected:
dev_logger.info(f"[SSH] Switching driver {conn.device_type} -> {detected}, reconnecting")
try:
conn.disconnect()
except Exception:
pass
try:
conn = _connect(detected, ip, username, password)
except Exception as e:
result["status"] = ST_CONN_FAIL
result["reason"] = f"Reconnect with {detected} failed: {_shorten_exc(e)}"
dev_logger.error(result["reason"])
return result
platform_type = detected
dev_logger.info(f"[Login] Login successful (platform={platform_type})")
result["used_platform"] = platform_type
# 2.5) On Cisco, actively enter privileged mode (skip on H3C)
if not ensure_enable_mode(conn, password, dev_logger, enable_password):
result["status"] = ST_AUTH_FAIL
result["reason"] = (
"Unable to enter privileged mode (enable failed). Check: (1) account has enable privilege; "
"(2) enable password is required (can be entered interactively with --enable-mode ask)"
)
return result
# 3) Hostname: prefer Excel value; detect from device if empty
if not name:
auto_name = detect_hostname(conn)
if auto_name:
name = auto_name
result["name"] = name
dev_logger.info(f"[Hostname] Excel value empty, using detected hostname: {auto_name}")
else:
name = ip
result["name"] = name
dev_logger.warning("[Hostname] Unable to detect hostname; using IP instead")
else:
dev_logger.info(f"[Hostname] Using hostname from Excel: {name}")
# 4) Dry-run: validate only, no configuration push
if dry_run:
result["status"] = ST_DRY_OK
result["reason"] = f"dry-run validation passed (platform={platform_type})"
dev_logger.info("[Dry-Run] Validation passed; configuration not pushed")
return result
# 5) Load template by model (strict one-to-one matching, skip if not found)
try:
lines, used_template = load_config_lines(
model, platform_type, name, configs_dir,
)
except (ValueError, FileNotFoundError) as e:
result["status"] = ST_NO_TEMPLATE
result["reason"] = str(e)
dev_logger.error(f"[Config] Skipping push: {e}")
return result
except Exception as e:
result["status"] = ST_UNKNOWN
result["reason"] = f"Failed to load configuration template: {_shorten_exc(e)}"
dev_logger.error(result["reason"])
return result
dev_logger.info(
f"[Config] Loaded {len(lines)} configuration lines "
f"(hostname={name}, model={model}, template={used_template.name})"
)
# 6) Push configuration
errs = push_config(conn, lines, platform_type, dev_logger)
if errs:
result["status"] = ST_CMD_ERROR
result["reason"] = (f"{len(errs)} command errors: "
+ "; ".join(errs[:3]))[:240]
else:
result["status"] = ST_SUCCESS
result["reason"] = f"Configuration push completed ({len(lines)} lines, template {used_template.name})"
return result
except Exception as e: # final fallback
dev_logger.exception(f"[Unknown] Unhandled exception: {e}")
result["status"] = ST_UNKNOWN
result["reason"] = f"Unknown exception: {_shorten_exc(e)}"
return result
finally:
if conn is not None:
try:
conn.disconnect()
except Exception:
pass
result["finished_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
dev_logger.info(f"========== Processing completed ({result['status']}) ==========")
# ============================ Excel read/write ============================
RESULT_COLUMNS = ["Status", "Reason", "Log Path", "Finished At"]
# Input columns (user-provided) and aliases for backward-compatible header matching
INPUT_COLUMN_ALIASES = {
"name": ["Device Name", "Hostname", "设备名称", "主机名"],
"ip": ["Management IP", "Device IP", "IP", "设备IP", "管理IP"],
"vendor": ["Vendor", "厂商", "厂商名称"],
"model": ["Model", "型号", "设备型号"],
"platform": ["Platform", "平台", "平台类型", "厂商类型"],
}
def _find_col(headers: list, names: list) -> Optional[int]:
for n in names:
if n in headers:
return headers.index(n) + 1
return None
def read_devices(xlsx_path: Path, only_ip: Optional[str] = None) -> list:
wb = load_workbook(xlsx_path)
ws = wb.active
headers = [c.value for c in ws[1]]
cols = {key: _find_col(headers, aliases)
for key, aliases in INPUT_COLUMN_ALIASES.items()}
if not cols["ip"]:
raise ValueError(
f"Excel must include an IP column (aliases: {INPUT_COLUMN_ALIASES['ip']}). "
f"Current headers: {headers}"
)
devices = []
for r in range(2, ws.max_row + 1):
ip = cell_str(ws, r, cols["ip"]) if cols["ip"] else ""
if not ip:
continue
if only_ip and ip != only_ip:
continue
devices.append({
"row_idx": r,
"ip": ip,
"name": cell_str(ws, r, cols["name"]) if cols["name"] else "",
"vendor": cell_str(ws, r, cols["vendor"]) if cols["vendor"] else "",
"model": cell_str(ws, r, cols["model"]) if cols["model"] else "",
"platform": cell_str(ws, r, cols["platform"]) if cols["platform"] else "",
})
return devices
def writeback_excel(xlsx_path: Path, results: list) -> int:
"""Write back 4 result columns (Status/Reason/Log Path/Finished At).
Also, when Excel "Platform" is empty/invalid and a valid platform is detected this run,
write the detected value back to "Platform" (fill-empty only, never overwrite valid
user input). Other user input columns (Device Name/Vendor/Model) are never modified.
Returns the number of rows where platform was auto-filled.