ntermqt 0.1.5__py3-none-any.whl → 0.1.7__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.
- nterm/parser/tfsm_fire_tester.py +561 -731
- nterm/scripting/__init__.py +8 -6
- nterm/scripting/api.py +96 -44
- nterm/scripting/repl.py +410 -0
- nterm/scripting/repl_interactive.py +418 -0
- nterm/session/local_terminal.py +1 -0
- {ntermqt-0.1.5.dist-info → ntermqt-0.1.7.dist-info}/METADATA +4 -1
- {ntermqt-0.1.5.dist-info → ntermqt-0.1.7.dist-info}/RECORD +11 -9
- {ntermqt-0.1.5.dist-info → ntermqt-0.1.7.dist-info}/WHEEL +0 -0
- {ntermqt-0.1.5.dist-info → ntermqt-0.1.7.dist-info}/entry_points.txt +0 -0
- {ntermqt-0.1.5.dist-info → ntermqt-0.1.7.dist-info}/top_level.txt +0 -0
nterm/scripting/__init__.py
CHANGED
|
@@ -15,11 +15,6 @@ Quick Start (IPython):
|
|
|
15
15
|
api.credentials() # List credentials
|
|
16
16
|
|
|
17
17
|
api.help() # Show all commands
|
|
18
|
-
|
|
19
|
-
Quick Start (CLI):
|
|
20
|
-
nterm-cli devices
|
|
21
|
-
nterm-cli search leaf
|
|
22
|
-
nterm-cli credentials --unlock
|
|
23
18
|
"""
|
|
24
19
|
|
|
25
20
|
from .api import (
|
|
@@ -29,9 +24,14 @@ from .api import (
|
|
|
29
24
|
get_api,
|
|
30
25
|
reset_api,
|
|
31
26
|
)
|
|
27
|
+
from nterm.scripting.repl import REPLPolicy, NTermREPL
|
|
28
|
+
from nterm.scripting.repl_interactive import add_repl_to_api
|
|
32
29
|
|
|
33
30
|
# Convenience: pre-instantiated API
|
|
34
|
-
api = get_api()
|
|
31
|
+
api = get_api() # <-- This FIRST
|
|
32
|
+
|
|
33
|
+
# Make api.repl() available
|
|
34
|
+
add_repl_to_api(api) # <-- Then this
|
|
35
35
|
|
|
36
36
|
__all__ = [
|
|
37
37
|
"NTermAPI",
|
|
@@ -40,4 +40,6 @@ __all__ = [
|
|
|
40
40
|
"get_api",
|
|
41
41
|
"reset_api",
|
|
42
42
|
"api",
|
|
43
|
+
'REPLPolicy',
|
|
44
|
+
'NTermREPL'
|
|
43
45
|
]
|
nterm/scripting/api.py
CHANGED
|
@@ -835,21 +835,25 @@ class NTermAPI:
|
|
|
835
835
|
|
|
836
836
|
return '\n'.join(cleaned_lines).strip()
|
|
837
837
|
|
|
838
|
-
def connect(self, device: str, credential: str = None) -> ActiveSession:
|
|
838
|
+
def connect(self, device: str, credential: str = None, debug: bool = False) -> ActiveSession:
|
|
839
839
|
"""
|
|
840
840
|
Connect to a device and detect platform.
|
|
841
841
|
|
|
842
842
|
Args:
|
|
843
843
|
device: Device name (from saved sessions) or hostname
|
|
844
844
|
credential: Optional credential name (auto-resolved if not specified)
|
|
845
|
+
debug: Enable verbose connection debugging
|
|
845
846
|
|
|
846
847
|
Returns:
|
|
847
848
|
ActiveSession handle for sending commands
|
|
848
|
-
|
|
849
|
-
Examples:
|
|
850
|
-
session = api.connect("spine1")
|
|
851
|
-
session = api.connect("192.168.1.1", credential="lab-admin")
|
|
852
849
|
"""
|
|
850
|
+
debug_log = []
|
|
851
|
+
|
|
852
|
+
def _debug(msg):
|
|
853
|
+
if debug:
|
|
854
|
+
debug_log.append(msg)
|
|
855
|
+
print(f"[DEBUG] {msg}")
|
|
856
|
+
|
|
853
857
|
# Look up device from saved sessions first
|
|
854
858
|
device_info = self.device(device)
|
|
855
859
|
|
|
@@ -859,21 +863,21 @@ class NTermAPI:
|
|
|
859
863
|
device_name = device_info.name
|
|
860
864
|
saved_cred = device_info.credential
|
|
861
865
|
else:
|
|
862
|
-
# Treat as hostname directly
|
|
863
866
|
hostname = device
|
|
864
867
|
port = 22
|
|
865
868
|
device_name = device
|
|
866
869
|
saved_cred = None
|
|
867
870
|
|
|
871
|
+
_debug(f"Target: {hostname}:{port}")
|
|
872
|
+
|
|
868
873
|
# Resolve credentials
|
|
869
874
|
if not self.vault_unlocked:
|
|
870
875
|
raise RuntimeError("Vault is locked. Call api.unlock(password) first.")
|
|
871
876
|
|
|
872
|
-
# Get credential - either specified or from saved session or auto-resolve
|
|
873
877
|
cred_name = credential or saved_cred
|
|
878
|
+
_debug(f"Credential: {cred_name or '(auto-resolve)'}")
|
|
874
879
|
|
|
875
880
|
if cred_name:
|
|
876
|
-
# User specified a credential name - use resolver's method
|
|
877
881
|
try:
|
|
878
882
|
profile = self._resolver.create_profile_for_credential(
|
|
879
883
|
credential_name=cred_name,
|
|
@@ -883,7 +887,6 @@ class NTermAPI:
|
|
|
883
887
|
except Exception as e:
|
|
884
888
|
raise ValueError(f"Failed to get credential '{cred_name}': {e}")
|
|
885
889
|
else:
|
|
886
|
-
# Auto-resolve based on hostname patterns
|
|
887
890
|
try:
|
|
888
891
|
profile = self._resolver.resolve_for_device(hostname, port=port)
|
|
889
892
|
except Exception as e:
|
|
@@ -892,10 +895,6 @@ class NTermAPI:
|
|
|
892
895
|
if not profile:
|
|
893
896
|
raise ValueError(f"No credentials available for {hostname}")
|
|
894
897
|
|
|
895
|
-
# Create SSH client
|
|
896
|
-
client = paramiko.SSHClient()
|
|
897
|
-
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|
898
|
-
|
|
899
898
|
# Apply legacy algorithm support
|
|
900
899
|
_apply_global_transport_settings()
|
|
901
900
|
|
|
@@ -909,24 +908,27 @@ class NTermAPI:
|
|
|
909
908
|
}
|
|
910
909
|
|
|
911
910
|
# Add authentication from profile
|
|
912
|
-
|
|
911
|
+
auth_method_used = None
|
|
913
912
|
if profile.auth_methods:
|
|
914
913
|
first_auth = profile.auth_methods[0]
|
|
915
914
|
connect_kwargs['username'] = first_auth.username
|
|
915
|
+
_debug(f"Username: {first_auth.username}")
|
|
916
916
|
|
|
917
|
-
# Try each auth method in order
|
|
918
917
|
for auth in profile.auth_methods:
|
|
919
918
|
if auth.method == AuthMethod.PASSWORD:
|
|
920
919
|
connect_kwargs['password'] = auth.password
|
|
920
|
+
auth_method_used = "password"
|
|
921
|
+
_debug("Auth method: password")
|
|
921
922
|
break
|
|
922
923
|
elif auth.method == AuthMethod.KEY_FILE:
|
|
923
|
-
connect_kwargs['key_filename'] = auth.
|
|
924
|
+
connect_kwargs['key_filename'] = auth.key_path
|
|
925
|
+
if auth.key_passphrase:
|
|
926
|
+
connect_kwargs['passphrase'] = auth.key_passphrase
|
|
927
|
+
auth_method_used = f"key_file:{auth.key_path}"
|
|
928
|
+
_debug(f"Auth method: key_file ({auth.key_path})")
|
|
924
929
|
break
|
|
925
930
|
elif auth.method == AuthMethod.KEY_STORED:
|
|
926
|
-
# KEY_STORED has key data as string, need to write to temp file
|
|
927
931
|
import tempfile
|
|
928
|
-
from io import StringIO
|
|
929
|
-
|
|
930
932
|
key_file = tempfile.NamedTemporaryFile(
|
|
931
933
|
mode='w',
|
|
932
934
|
delete=False,
|
|
@@ -934,42 +936,93 @@ class NTermAPI:
|
|
|
934
936
|
)
|
|
935
937
|
key_file.write(auth.key_data)
|
|
936
938
|
key_file.close()
|
|
937
|
-
|
|
938
939
|
connect_kwargs['key_filename'] = key_file.name
|
|
939
940
|
if auth.key_passphrase:
|
|
940
941
|
connect_kwargs['passphrase'] = auth.key_passphrase
|
|
942
|
+
auth_method_used = "key_stored"
|
|
943
|
+
_debug(f"Auth method: key_stored (temp: {key_file.name})")
|
|
941
944
|
break
|
|
942
945
|
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
946
|
+
# Detect key type if using key auth
|
|
947
|
+
if 'key_filename' in connect_kwargs:
|
|
948
|
+
key_path = connect_kwargs['key_filename']
|
|
949
|
+
key_type = "unknown"
|
|
950
|
+
key_bits = None
|
|
951
|
+
try:
|
|
952
|
+
key = paramiko.RSAKey.from_private_key_file(key_path)
|
|
953
|
+
key_type = "RSA"
|
|
954
|
+
key_bits = key.get_bits()
|
|
955
|
+
except:
|
|
956
|
+
try:
|
|
957
|
+
key = paramiko.Ed25519Key.from_private_key_file(key_path)
|
|
958
|
+
key_type = "Ed25519"
|
|
959
|
+
except:
|
|
960
|
+
try:
|
|
961
|
+
key = paramiko.ECDSAKey.from_private_key_file(key_path)
|
|
962
|
+
key_type = "ECDSA"
|
|
963
|
+
except:
|
|
964
|
+
pass
|
|
965
|
+
_debug(f"Key type: {key_type}" + (f" ({key_bits} bits)" if key_bits else ""))
|
|
966
|
+
|
|
967
|
+
# Connection attempt sequence
|
|
968
|
+
attempts = [
|
|
969
|
+
("modern", None),
|
|
970
|
+
("rsa-sha1", RSA_SHA1_DISABLED_ALGORITHMS),
|
|
971
|
+
]
|
|
952
972
|
|
|
953
|
-
|
|
973
|
+
last_error = None
|
|
974
|
+
connected = False
|
|
975
|
+
client = None
|
|
976
|
+
|
|
977
|
+
for attempt_name, disabled_algs in attempts:
|
|
978
|
+
client = paramiko.SSHClient()
|
|
979
|
+
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|
980
|
+
|
|
981
|
+
_debug(f"Attempt: {attempt_name}")
|
|
982
|
+
|
|
983
|
+
attempt_kwargs = connect_kwargs.copy()
|
|
984
|
+
if disabled_algs:
|
|
985
|
+
attempt_kwargs['disabled_algorithms'] = disabled_algs
|
|
986
|
+
_debug(f" disabled_algorithms: {disabled_algs}")
|
|
987
|
+
|
|
988
|
+
try:
|
|
989
|
+
client.connect(**attempt_kwargs)
|
|
990
|
+
connected = True
|
|
991
|
+
|
|
992
|
+
# Log successful negotiation
|
|
954
993
|
transport = client.get_transport()
|
|
955
994
|
if transport:
|
|
956
|
-
transport.
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
)
|
|
995
|
+
_debug(f" SUCCESS - cipher: {transport.remote_cipher}, mac: {transport.remote_mac}")
|
|
996
|
+
_debug(f" host_key_type: {transport.host_key_type}")
|
|
997
|
+
break
|
|
960
998
|
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
999
|
+
except paramiko.AuthenticationException as e:
|
|
1000
|
+
_debug(f" FAILED (auth): {e}")
|
|
1001
|
+
last_error = str(e)
|
|
1002
|
+
client.close()
|
|
1003
|
+
except paramiko.SSHException as e:
|
|
1004
|
+
_debug(f" FAILED (ssh): {e}")
|
|
1005
|
+
last_error = str(e)
|
|
1006
|
+
client.close()
|
|
1007
|
+
except Exception as e:
|
|
1008
|
+
_debug(f" FAILED (other): {e}")
|
|
1009
|
+
last_error = str(e)
|
|
1010
|
+
client.close()
|
|
1011
|
+
|
|
1012
|
+
if not connected:
|
|
1013
|
+
# Build detailed error message
|
|
1014
|
+
error_detail = f"Connection failed: {last_error}"
|
|
1015
|
+
if debug:
|
|
1016
|
+
error_detail += f"\n\nDebug log:\n" + "\n".join(debug_log)
|
|
1017
|
+
raise paramiko.AuthenticationException(error_detail)
|
|
964
1018
|
|
|
965
1019
|
# Open interactive shell
|
|
966
1020
|
shell = client.invoke_shell(width=200, height=50)
|
|
967
1021
|
shell.settimeout(0.5)
|
|
968
1022
|
|
|
969
|
-
# Wait for initial prompt
|
|
970
1023
|
prompt = self._wait_for_prompt(shell)
|
|
1024
|
+
_debug(f"Prompt detected: {prompt}")
|
|
971
1025
|
|
|
972
|
-
# Create session object
|
|
973
1026
|
session = ActiveSession(
|
|
974
1027
|
device_name=device_name,
|
|
975
1028
|
hostname=hostname,
|
|
@@ -984,10 +1037,11 @@ class NTermAPI:
|
|
|
984
1037
|
version_output = self._send_command(shell, "show version", prompt)
|
|
985
1038
|
platform = self._detect_platform(version_output)
|
|
986
1039
|
session.platform = platform
|
|
1040
|
+
_debug(f"Platform detected: {platform}")
|
|
987
1041
|
except Exception as e:
|
|
988
|
-
|
|
1042
|
+
_debug(f"Platform detection failed: {e}")
|
|
989
1043
|
|
|
990
|
-
# Disable terminal paging
|
|
1044
|
+
# Disable terminal paging
|
|
991
1045
|
try:
|
|
992
1046
|
if session.platform and 'cisco' in session.platform:
|
|
993
1047
|
self._send_command(shell, "terminal length 0", prompt, timeout=5)
|
|
@@ -996,11 +1050,9 @@ class NTermAPI:
|
|
|
996
1050
|
elif session.platform == 'arista_eos':
|
|
997
1051
|
self._send_command(shell, "terminal length 0", prompt, timeout=5)
|
|
998
1052
|
except Exception as e:
|
|
999
|
-
|
|
1053
|
+
_debug(f"Failed to disable paging: {e}")
|
|
1000
1054
|
|
|
1001
|
-
# Track active session
|
|
1002
1055
|
self._active_sessions[device_name] = session
|
|
1003
|
-
|
|
1004
1056
|
return session
|
|
1005
1057
|
|
|
1006
1058
|
def send(
|
nterm/scripting/repl.py
ADDED
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
# nterm/scripting/repl.py
|
|
2
|
+
#
|
|
3
|
+
# A single "front door" that BOTH humans (GUI REPL) and MCP use.
|
|
4
|
+
# Guardrails live here, not in the agent.
|
|
5
|
+
#
|
|
6
|
+
# - Allow-list commands (or allow-list verbs + deny-list verbs)
|
|
7
|
+
# - Optional read-only mode
|
|
8
|
+
# - Session scoping (one device/session per REPL unless explicitly allowed)
|
|
9
|
+
# - Audit log of everything that ran
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import json
|
|
15
|
+
import time
|
|
16
|
+
import shlex
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from datetime import datetime
|
|
19
|
+
from typing import Optional, Dict, Any, List
|
|
20
|
+
|
|
21
|
+
from .api import NTermAPI, ActiveSession, CommandResult
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class REPLPolicy:
|
|
26
|
+
mode: str = "read_only" # "read_only" or "ops"
|
|
27
|
+
max_output_chars: int = 250000
|
|
28
|
+
max_command_seconds: int = 60
|
|
29
|
+
|
|
30
|
+
# Simple and surprisingly effective "don't brick the network" guardrails
|
|
31
|
+
deny_substrings: List[str] = field(default_factory=list)
|
|
32
|
+
allow_prefixes: List[str] = field(default_factory=list)
|
|
33
|
+
|
|
34
|
+
def is_allowed(self, command: str) -> bool:
|
|
35
|
+
cmd = command.strip().lower()
|
|
36
|
+
|
|
37
|
+
i = 0
|
|
38
|
+
while i < len(self.deny_substrings):
|
|
39
|
+
bad = self.deny_substrings[i].lower()
|
|
40
|
+
if bad in cmd:
|
|
41
|
+
return False
|
|
42
|
+
i += 1
|
|
43
|
+
|
|
44
|
+
if self.mode == "read_only":
|
|
45
|
+
# Cheap "write reminder": block common config verbs
|
|
46
|
+
# (You can tighten to an allow-list only if you want)
|
|
47
|
+
write_verbs = [
|
|
48
|
+
"conf t",
|
|
49
|
+
"configure",
|
|
50
|
+
"copy ",
|
|
51
|
+
"write",
|
|
52
|
+
"wr ",
|
|
53
|
+
"reload",
|
|
54
|
+
"commit",
|
|
55
|
+
"delete",
|
|
56
|
+
"set ",
|
|
57
|
+
"unset ",
|
|
58
|
+
"clear ",
|
|
59
|
+
"shutdown",
|
|
60
|
+
"no shutdown",
|
|
61
|
+
"format",
|
|
62
|
+
"upgrade",
|
|
63
|
+
"install",
|
|
64
|
+
]
|
|
65
|
+
j = 0
|
|
66
|
+
while j < len(write_verbs):
|
|
67
|
+
if cmd.startswith(write_verbs[j]):
|
|
68
|
+
return False
|
|
69
|
+
j += 1
|
|
70
|
+
|
|
71
|
+
# If allow_prefixes provided, require one of them
|
|
72
|
+
if self.allow_prefixes:
|
|
73
|
+
ok = False
|
|
74
|
+
k = 0
|
|
75
|
+
while k < len(self.allow_prefixes):
|
|
76
|
+
pref = self.allow_prefixes[k].lower()
|
|
77
|
+
if cmd.startswith(pref):
|
|
78
|
+
ok = True
|
|
79
|
+
break
|
|
80
|
+
k += 1
|
|
81
|
+
return ok
|
|
82
|
+
|
|
83
|
+
return True
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass
|
|
87
|
+
class REPLState:
|
|
88
|
+
api: NTermAPI
|
|
89
|
+
policy: REPLPolicy
|
|
90
|
+
vault_unlocked: bool = False
|
|
91
|
+
session: Optional[ActiveSession] = None
|
|
92
|
+
connected_device: Optional[str] = None
|
|
93
|
+
output_mode: str = "parsed" # "raw" or "parsed"
|
|
94
|
+
output_format: str = "text" # "text", "rich", or "json" (for parsed mode only)
|
|
95
|
+
platform_hint: Optional[str] = None # Override platform for TextFSM
|
|
96
|
+
debug_mode: bool = False # Show full result data
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class NTermREPL:
|
|
100
|
+
"""
|
|
101
|
+
A minimal command router. This is the "tool surface".
|
|
102
|
+
MCP can call `handle_line()`; humans can type into it.
|
|
103
|
+
|
|
104
|
+
Commands:
|
|
105
|
+
:unlock
|
|
106
|
+
:lock
|
|
107
|
+
:creds [pattern]
|
|
108
|
+
:devices [pattern]
|
|
109
|
+
:connect <device> [--cred name]
|
|
110
|
+
:disconnect
|
|
111
|
+
:policy [read_only|ops]
|
|
112
|
+
:mode [raw|parsed]
|
|
113
|
+
:format [text|rich|json]
|
|
114
|
+
:set_hint <platform>
|
|
115
|
+
:clear_hint
|
|
116
|
+
:debug [on|off]
|
|
117
|
+
:dbinfo
|
|
118
|
+
(anything else runs as CLI on the connected session)
|
|
119
|
+
:help
|
|
120
|
+
:exit
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
def __init__(self, api: Optional[NTermAPI] = None, policy: Optional[REPLPolicy] = None):
|
|
124
|
+
if api is None:
|
|
125
|
+
api = NTermAPI()
|
|
126
|
+
if policy is None:
|
|
127
|
+
policy = REPLPolicy(
|
|
128
|
+
mode="read_only",
|
|
129
|
+
deny_substrings=[
|
|
130
|
+
"terminal monitor", # example if you don't want interactive spam
|
|
131
|
+
],
|
|
132
|
+
allow_prefixes=[],
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
self.state = REPLState(api=api, policy=policy)
|
|
136
|
+
|
|
137
|
+
def handle_line(self, line: str) -> Dict[str, Any]:
|
|
138
|
+
line = (line or "").strip()
|
|
139
|
+
if not line:
|
|
140
|
+
return self._ok({"type": "noop"})
|
|
141
|
+
|
|
142
|
+
if line.startswith(":"):
|
|
143
|
+
return self._handle_meta(line)
|
|
144
|
+
|
|
145
|
+
# Default: treat as CLI to send
|
|
146
|
+
return self._handle_send(line)
|
|
147
|
+
|
|
148
|
+
# -----------------------
|
|
149
|
+
# Meta / REPL commands
|
|
150
|
+
# -----------------------
|
|
151
|
+
|
|
152
|
+
def _handle_meta(self, line: str) -> Dict[str, Any]:
|
|
153
|
+
parts = shlex.split(line)
|
|
154
|
+
cmd = parts[0].lower()
|
|
155
|
+
|
|
156
|
+
if cmd == ":help":
|
|
157
|
+
return self._ok({"type": "help", "text": self._help_text()})
|
|
158
|
+
|
|
159
|
+
if cmd == ":exit":
|
|
160
|
+
if self.state.session:
|
|
161
|
+
self._safe_disconnect()
|
|
162
|
+
return self._ok({"type": "exit"})
|
|
163
|
+
|
|
164
|
+
if cmd == ":unlock":
|
|
165
|
+
# Password should be provided separately, not in the command line
|
|
166
|
+
if len(parts) > 1:
|
|
167
|
+
return self._err(":unlock takes no arguments. Password will be prompted securely.")
|
|
168
|
+
return self._ok({"type": "unlock_prompt", "message": "Please provide vault password"})
|
|
169
|
+
|
|
170
|
+
if cmd == ":lock":
|
|
171
|
+
self.state.api.lock()
|
|
172
|
+
self.state.vault_unlocked = False
|
|
173
|
+
return self._ok({"type": "lock", "vault_unlocked": False})
|
|
174
|
+
|
|
175
|
+
if cmd == ":creds":
|
|
176
|
+
if not self.state.api.vault_unlocked:
|
|
177
|
+
return self._err("Vault is locked. Run :unlock <password> first.")
|
|
178
|
+
|
|
179
|
+
pattern = None
|
|
180
|
+
if len(parts) >= 2:
|
|
181
|
+
pattern = parts[1]
|
|
182
|
+
|
|
183
|
+
creds = self.state.api.credentials(pattern=pattern)
|
|
184
|
+
rows: List[Dict[str, Any]] = []
|
|
185
|
+
i = 0
|
|
186
|
+
while i < len(creds):
|
|
187
|
+
rows.append(creds[i].to_dict())
|
|
188
|
+
i += 1
|
|
189
|
+
return self._ok({"type": "credentials", "credentials": rows})
|
|
190
|
+
|
|
191
|
+
if cmd == ":devices":
|
|
192
|
+
pattern = None
|
|
193
|
+
if len(parts) >= 2:
|
|
194
|
+
pattern = parts[1]
|
|
195
|
+
devs = self.state.api.devices(pattern=pattern)
|
|
196
|
+
# No comprehensions
|
|
197
|
+
rows: List[Dict[str, Any]] = []
|
|
198
|
+
i = 0
|
|
199
|
+
while i < len(devs):
|
|
200
|
+
rows.append(devs[i].to_dict())
|
|
201
|
+
i += 1
|
|
202
|
+
return self._ok({"type": "devices", "devices": rows})
|
|
203
|
+
|
|
204
|
+
if cmd == ":connect":
|
|
205
|
+
if len(parts) < 2:
|
|
206
|
+
return self._err("Usage: :connect <device> [--cred name]")
|
|
207
|
+
|
|
208
|
+
device = parts[1]
|
|
209
|
+
cred = None
|
|
210
|
+
|
|
211
|
+
i = 2
|
|
212
|
+
while i < len(parts):
|
|
213
|
+
if parts[i] == "--cred":
|
|
214
|
+
if i + 1 < len(parts):
|
|
215
|
+
cred = parts[i + 1]
|
|
216
|
+
i += 1
|
|
217
|
+
i += 1
|
|
218
|
+
|
|
219
|
+
if not self.state.api.vault_unlocked:
|
|
220
|
+
return self._err("Vault is locked. Run :unlock <password> first.")
|
|
221
|
+
|
|
222
|
+
if self.state.session:
|
|
223
|
+
self._safe_disconnect()
|
|
224
|
+
|
|
225
|
+
try:
|
|
226
|
+
# Pass debug flag from REPL state
|
|
227
|
+
sess = self.state.api.connect(
|
|
228
|
+
device,
|
|
229
|
+
credential=cred,
|
|
230
|
+
debug=self.state.debug_mode # <-- This line
|
|
231
|
+
)
|
|
232
|
+
self.state.session = sess
|
|
233
|
+
self.state.connected_device = sess.device_name
|
|
234
|
+
|
|
235
|
+
return self._ok({
|
|
236
|
+
"type": "connect",
|
|
237
|
+
"device": sess.device_name,
|
|
238
|
+
"hostname": sess.hostname,
|
|
239
|
+
"port": sess.port,
|
|
240
|
+
"platform": sess.platform,
|
|
241
|
+
"prompt": sess.prompt,
|
|
242
|
+
})
|
|
243
|
+
except Exception as e:
|
|
244
|
+
return self._err(f"Connection failed: {e}")
|
|
245
|
+
|
|
246
|
+
if cmd == ":disconnect":
|
|
247
|
+
self._safe_disconnect()
|
|
248
|
+
return self._ok({"type": "disconnect"})
|
|
249
|
+
|
|
250
|
+
if cmd == ":policy":
|
|
251
|
+
if len(parts) < 2:
|
|
252
|
+
return self._ok({"type": "policy", "mode": self.state.policy.mode})
|
|
253
|
+
mode = parts[1].lower()
|
|
254
|
+
if mode not in ["read_only", "ops"]:
|
|
255
|
+
return self._err("Policy must be read_only or ops")
|
|
256
|
+
self.state.policy.mode = mode
|
|
257
|
+
return self._ok({"type": "policy", "mode": mode})
|
|
258
|
+
|
|
259
|
+
if cmd == ":mode":
|
|
260
|
+
if len(parts) < 2:
|
|
261
|
+
return self._ok({
|
|
262
|
+
"type": "mode",
|
|
263
|
+
"mode": self.state.output_mode,
|
|
264
|
+
"platform_hint": self.state.platform_hint,
|
|
265
|
+
})
|
|
266
|
+
mode = parts[1].lower()
|
|
267
|
+
if mode not in ["raw", "parsed"]:
|
|
268
|
+
return self._err("Mode must be 'raw' or 'parsed'")
|
|
269
|
+
self.state.output_mode = mode
|
|
270
|
+
return self._ok({"type": "mode", "mode": mode})
|
|
271
|
+
|
|
272
|
+
if cmd == ":format":
|
|
273
|
+
if len(parts) < 2:
|
|
274
|
+
return self._ok({
|
|
275
|
+
"type": "format",
|
|
276
|
+
"format": self.state.output_format,
|
|
277
|
+
})
|
|
278
|
+
fmt = parts[1].lower()
|
|
279
|
+
if fmt not in ["text", "rich", "json"]:
|
|
280
|
+
return self._err("Format must be 'text', 'rich', or 'json'")
|
|
281
|
+
self.state.output_format = fmt
|
|
282
|
+
return self._ok({"type": "format", "format": fmt})
|
|
283
|
+
|
|
284
|
+
if cmd == ":set_hint":
|
|
285
|
+
if len(parts) < 2:
|
|
286
|
+
return self._err("Usage: :set_hint <platform> (e.g., cisco_ios, arista_eos)")
|
|
287
|
+
platform = parts[1].lower()
|
|
288
|
+
self.state.platform_hint = platform
|
|
289
|
+
return self._ok({"type": "set_hint", "platform_hint": platform})
|
|
290
|
+
|
|
291
|
+
if cmd == ":clear_hint":
|
|
292
|
+
self.state.platform_hint = None
|
|
293
|
+
return self._ok({"type": "clear_hint"})
|
|
294
|
+
|
|
295
|
+
if cmd == ":debug":
|
|
296
|
+
if len(parts) >= 2:
|
|
297
|
+
mode = parts[1].lower()
|
|
298
|
+
if mode in ["on", "true", "1"]:
|
|
299
|
+
self.state.debug_mode = True
|
|
300
|
+
elif mode in ["off", "false", "0"]:
|
|
301
|
+
self.state.debug_mode = False
|
|
302
|
+
else:
|
|
303
|
+
return self._err("Debug mode must be on or off")
|
|
304
|
+
else:
|
|
305
|
+
# Toggle
|
|
306
|
+
self.state.debug_mode = not self.state.debug_mode
|
|
307
|
+
return self._ok({"type": "debug", "debug_mode": self.state.debug_mode})
|
|
308
|
+
|
|
309
|
+
if cmd == ":dbinfo":
|
|
310
|
+
try:
|
|
311
|
+
db_info = self.state.api.db_info()
|
|
312
|
+
return self._ok({"type": "dbinfo", "db_info": db_info})
|
|
313
|
+
except Exception as e:
|
|
314
|
+
return self._err(f"Failed to get DB info: {e}")
|
|
315
|
+
|
|
316
|
+
return self._err(f"Unknown REPL command: {cmd}")
|
|
317
|
+
|
|
318
|
+
# -----------------------
|
|
319
|
+
# CLI send path
|
|
320
|
+
# -----------------------
|
|
321
|
+
|
|
322
|
+
def _handle_send(self, cli: str) -> Dict[str, Any]:
|
|
323
|
+
if not self.state.session:
|
|
324
|
+
return self._err("Not connected. Use :connect <device>")
|
|
325
|
+
|
|
326
|
+
if not self.state.policy.is_allowed(cli):
|
|
327
|
+
return self._err(f"Blocked by policy ({self.state.policy.mode}): {cli}")
|
|
328
|
+
|
|
329
|
+
try:
|
|
330
|
+
started = time.time()
|
|
331
|
+
|
|
332
|
+
# Determine if we should parse based on output mode
|
|
333
|
+
should_parse = (self.state.output_mode == "parsed")
|
|
334
|
+
|
|
335
|
+
# Apply platform hint if set (modify session platform temporarily)
|
|
336
|
+
original_platform = self.state.session.platform
|
|
337
|
+
if self.state.platform_hint:
|
|
338
|
+
self.state.session.platform = self.state.platform_hint
|
|
339
|
+
|
|
340
|
+
try:
|
|
341
|
+
res: CommandResult = self.state.api.send(
|
|
342
|
+
self.state.session,
|
|
343
|
+
cli,
|
|
344
|
+
timeout=self.state.policy.max_command_seconds,
|
|
345
|
+
parse=should_parse,
|
|
346
|
+
normalize=True,
|
|
347
|
+
)
|
|
348
|
+
finally:
|
|
349
|
+
# Restore original platform
|
|
350
|
+
if self.state.platform_hint:
|
|
351
|
+
self.state.session.platform = original_platform
|
|
352
|
+
|
|
353
|
+
elapsed = time.time() - started
|
|
354
|
+
|
|
355
|
+
# Clip raw output for safety/transport
|
|
356
|
+
raw = res.raw_output or ""
|
|
357
|
+
if len(raw) > self.state.policy.max_output_chars:
|
|
358
|
+
raw = raw[: self.state.policy.max_output_chars] + "\n...<truncated>..."
|
|
359
|
+
|
|
360
|
+
payload = res.to_dict()
|
|
361
|
+
payload["raw_output"] = raw
|
|
362
|
+
payload["elapsed_seconds"] = round(elapsed, 3)
|
|
363
|
+
|
|
364
|
+
return self._ok({"type": "result", "result": payload})
|
|
365
|
+
except Exception as e:
|
|
366
|
+
return self._err(f"Command execution failed: {e}")
|
|
367
|
+
|
|
368
|
+
def _safe_disconnect(self) -> None:
|
|
369
|
+
if self.state.session:
|
|
370
|
+
try:
|
|
371
|
+
self.state.api.disconnect(self.state.session)
|
|
372
|
+
finally:
|
|
373
|
+
self.state.session = None
|
|
374
|
+
self.connected_device = None
|
|
375
|
+
|
|
376
|
+
def do_unlock(self, password: str) -> Dict[str, Any]:
|
|
377
|
+
"""Internal method to perform unlock with password."""
|
|
378
|
+
try:
|
|
379
|
+
ok = self.state.api.unlock(password)
|
|
380
|
+
self.state.vault_unlocked = bool(ok)
|
|
381
|
+
return self._ok({"type": "unlock", "vault_unlocked": self.state.vault_unlocked})
|
|
382
|
+
except Exception as e:
|
|
383
|
+
return self._err(f"Unlock failed: {e}")
|
|
384
|
+
|
|
385
|
+
def _help_text(self) -> str:
|
|
386
|
+
return (
|
|
387
|
+
"Commands:\n"
|
|
388
|
+
" :unlock (prompts for vault password securely)\n"
|
|
389
|
+
" :lock\n"
|
|
390
|
+
" :creds [pattern]\n"
|
|
391
|
+
" :devices [pattern]\n"
|
|
392
|
+
" :connect <device> [--cred name]\n"
|
|
393
|
+
" :disconnect\n"
|
|
394
|
+
" :policy [read_only|ops]\n"
|
|
395
|
+
" :mode [raw|parsed] (control output format, default: parsed)\n"
|
|
396
|
+
" :format [text|rich|json] (parsed mode display format, default: text)\n"
|
|
397
|
+
" :set_hint <platform> (override TextFSM platform, e.g., cisco_ios)\n"
|
|
398
|
+
" :clear_hint (use auto-detected platform)\n"
|
|
399
|
+
" :debug [on|off] (show full result data for troubleshooting)\n"
|
|
400
|
+
" :dbinfo (show TextFSM database status)\n"
|
|
401
|
+
" (anything else runs as CLI on the connected session)\n"
|
|
402
|
+
" :help\n"
|
|
403
|
+
" :exit\n"
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
def _ok(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
|
407
|
+
return {"ok": True, "data": data, "ts": datetime.now().isoformat()}
|
|
408
|
+
|
|
409
|
+
def _err(self, message: str) -> Dict[str, Any]:
|
|
410
|
+
return {"ok": False, "error": message, "ts": datetime.now().isoformat()}
|