fix: fix_snmp SSH fallback when driver is connected via REST API
The REST API has no writable config endpoint for SNMP communities. When _action_fix_snmp() is called on a REST-connected driver, it now opens a temporary Netmiko SSH session (standard KEX, then legacy KEX fallback), runs the config-mode CLI commands, and restores the REST transport state on exit. SSH errors are surfaced verbatim instead of being silently swallowed. Also: - _save_config: guard against REST transport (was calling self._device directly → NoneType AttributeError when transport == "api") - _action_fix_snmp: extracted CLI logic into _action_fix_snmp_cli() so it is reused for both the SSH-fallback and native SSH/Telnet paths - api_client: add get_snmp_communities() + configure_snmp_community() for future use; improve GET error logging (warn vs debug on non-404) - _try_api: probe timeout capped at 15 s; SSL retry loop for self-signed certificates; improved log levels - _api_set_interface / _cli_set_interface: handle enabled/description fields that were previously ignored - probe(): broaden accepted status codes (301/302/303/403) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
aa55eb2bb9
commit
0cab237b1d
@@ -79,7 +79,9 @@ class ProcurveApiClient:
|
|||||||
# 401 = API exists but not authenticated (most common)
|
# 401 = API exists but not authenticated (most common)
|
||||||
# 200 = API exists and accessible without auth (unusual)
|
# 200 = API exists and accessible without auth (unusual)
|
||||||
# 405 = wrong HTTP verb but API is there
|
# 405 = wrong HTTP verb but API is there
|
||||||
if resp.status_code in (200, 401, 405):
|
# 302/301/303 = redirect to login page (older firmware)
|
||||||
|
# 403 = API exists but access forbidden
|
||||||
|
if resp.status_code in (200, 301, 302, 303, 401, 403, 405):
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"API detected at %s (HTTP %s, %s %s)",
|
"API detected at %s (HTTP %s, %s %s)",
|
||||||
hostname, resp.status_code, proto, ver,
|
hostname, resp.status_code, proto, ver,
|
||||||
@@ -169,9 +171,12 @@ class ProcurveApiClient:
|
|||||||
resp = self._session.get(url, timeout=self.timeout)
|
resp = self._session.get(url, timeout=self.timeout)
|
||||||
if resp.ok:
|
if resp.ok:
|
||||||
return resp.json()
|
return resp.json()
|
||||||
logger.debug("GET %s returned HTTP %s", url, resp.status_code)
|
if resp.status_code == 404:
|
||||||
|
logger.debug("GET %s returned HTTP 404", url)
|
||||||
|
else:
|
||||||
|
logger.warning("GET %s returned HTTP %s: %s", url, resp.status_code, resp.text[:200])
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.debug("GET %s error: %s", url, exc)
|
logger.warning("GET %s error: %s", url, exc)
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
def post(self, endpoint: str, **kwargs: Any) -> requests.Response:
|
def post(self, endpoint: str, **kwargs: Any) -> requests.Response:
|
||||||
@@ -481,6 +486,36 @@ class ProcurveApiClient:
|
|||||||
f"delete_vlan({vlan_id}): REST API returned HTTP {resp.status_code}"
|
f"delete_vlan({vlan_id}): REST API returned HTTP {resp.status_code}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def get_snmp_communities(self) -> Dict[str, Dict]:
|
||||||
|
"""Return configured SNMP communities from REST API."""
|
||||||
|
data = self.get("snmp-server/community")
|
||||||
|
result: Dict[str, Dict] = {}
|
||||||
|
for entry in data.get("snmp_community_element", []):
|
||||||
|
name = entry.get("community_name", "")
|
||||||
|
if name:
|
||||||
|
result[name] = {"access_type": entry.get("access_type", "")}
|
||||||
|
return result
|
||||||
|
|
||||||
|
def configure_snmp_community(self, community: str = "public") -> Tuple[bool, str]:
|
||||||
|
"""Ensure an SNMP read-only community exists via REST resource endpoint.
|
||||||
|
|
||||||
|
Returns (success, message).
|
||||||
|
"""
|
||||||
|
existing = self.get_snmp_communities()
|
||||||
|
if community in existing:
|
||||||
|
return True, f"SNMP community '{community}' already exists."
|
||||||
|
|
||||||
|
resp = self.post("snmp-server/community", json={
|
||||||
|
"community_name": community,
|
||||||
|
"access_type": "MIB2",
|
||||||
|
})
|
||||||
|
if resp.ok:
|
||||||
|
return True, f"SNMP community '{community}' configured via REST API."
|
||||||
|
if resp.status_code == 409:
|
||||||
|
return True, f"SNMP community '{community}' already exists."
|
||||||
|
|
||||||
|
return False, f"REST API returned HTTP {resp.status_code}: {resp.text[:200]}"
|
||||||
|
|
||||||
def ping(
|
def ping(
|
||||||
self,
|
self,
|
||||||
destination: str,
|
destination: str,
|
||||||
|
|||||||
+101
-20
@@ -203,38 +203,47 @@ class ProcurveDriver(SwitchDriver):
|
|||||||
|
|
||||||
def _try_api(self) -> bool:
|
def _try_api(self) -> bool:
|
||||||
"""Probe and connect via REST API. Returns True on success."""
|
"""Probe and connect via REST API. Returns True on success."""
|
||||||
logger.debug("Trying REST API for %s", self.hostname)
|
probe_timeout = min(self.timeout, 15)
|
||||||
|
logger.info("Probing REST API for %s (timeout=%ds)", self.hostname, probe_timeout)
|
||||||
|
# Probe without SSL verification — no credentials are sent during probing,
|
||||||
|
# so this is safe and avoids failing on self-signed certificates.
|
||||||
ver, proto = ProcurveApiClient.probe(
|
ver, proto = ProcurveApiClient.probe(
|
||||||
self.hostname, timeout=5, ssl_verify=self.ssl_verify
|
self.hostname, timeout=probe_timeout, ssl_verify=False
|
||||||
)
|
)
|
||||||
# Allow hint override (e.g. user knows the API version)
|
# Allow hint override (e.g. user knows the API version)
|
||||||
if self.api_version_hint and proto:
|
if self.api_version_hint and proto:
|
||||||
ver = self.api_version_hint
|
ver = self.api_version_hint
|
||||||
|
|
||||||
if not ver:
|
if not ver:
|
||||||
logger.debug("REST API not available on %s", self.hostname)
|
logger.warning("REST API not detected on %s — falling back to CLI", self.hostname)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# Try connecting with the requested SSL setting first; if it fails due to a
|
||||||
|
# self-signed certificate (ssl_verify=True), transparently retry unverified.
|
||||||
|
for ssl_verify in ([self.ssl_verify] if not self.ssl_verify else [True, False]):
|
||||||
client = ProcurveApiClient(
|
client = ProcurveApiClient(
|
||||||
hostname=self.hostname,
|
hostname=self.hostname,
|
||||||
username=self.username,
|
username=self.username,
|
||||||
password=self.password,
|
password=self.password,
|
||||||
timeout=self.timeout,
|
timeout=self.timeout,
|
||||||
ssl_verify=self.ssl_verify,
|
ssl_verify=ssl_verify,
|
||||||
api_version=ver,
|
api_version=ver,
|
||||||
)
|
)
|
||||||
client.setup(ver, proto)
|
client.setup(ver, proto)
|
||||||
try:
|
try:
|
||||||
client.connect()
|
client.connect()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.debug("REST API connect failed: %s", exc)
|
logger.debug("REST API connect failed (ssl_verify=%s): %s", ssl_verify, exc)
|
||||||
return False
|
continue
|
||||||
|
|
||||||
self._api = client
|
self._api = client
|
||||||
self._transport = "api"
|
self._transport = "api"
|
||||||
logger.info("Connected to %s via REST API (%s %s)", self.hostname, proto, ver)
|
logger.info("Connected to %s via REST API (%s %s, ssl_verify=%s)",
|
||||||
|
self.hostname, proto, ver, ssl_verify)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
logger.warning("REST API connect failed for %s — falling back to CLI", self.hostname)
|
||||||
|
return False
|
||||||
|
|
||||||
def _try_ssh(self, legacy: bool = False) -> bool:
|
def _try_ssh(self, legacy: bool = False) -> bool:
|
||||||
"""Probe and connect via SSH. Returns True on success."""
|
"""Probe and connect via SSH. Returns True on success."""
|
||||||
label = "SSH-legacy" if legacy else "SSH"
|
label = "SSH-legacy" if legacy else "SSH"
|
||||||
@@ -351,6 +360,9 @@ class ProcurveDriver(SwitchDriver):
|
|||||||
|
|
||||||
def _save_config(self) -> None:
|
def _save_config(self) -> None:
|
||||||
"""Save running configuration to startup (``write memory``)."""
|
"""Save running configuration to startup (``write memory``)."""
|
||||||
|
if self._transport == "api":
|
||||||
|
self._api.post("cli", json={"cmd": "write memory"})
|
||||||
|
return
|
||||||
self._device.send_command(
|
self._device.send_command(
|
||||||
"write memory",
|
"write memory",
|
||||||
expect_string=self._exec_prompt(),
|
expect_string=self._exec_prompt(),
|
||||||
@@ -780,6 +792,17 @@ class ProcurveDriver(SwitchDriver):
|
|||||||
self._cli_set_interface(interface, config)
|
self._cli_set_interface(interface, config)
|
||||||
|
|
||||||
def _api_set_interface(self, interface: str, config: InterfaceConfigDict) -> None:
|
def _api_set_interface(self, interface: str, config: InterfaceConfigDict) -> None:
|
||||||
|
patch: dict = {"id": interface}
|
||||||
|
if "enabled" in config:
|
||||||
|
patch["is_port_enabled"] = bool(config["enabled"])
|
||||||
|
if "description" in config:
|
||||||
|
patch["name"] = config["description"]
|
||||||
|
if len(patch) > 1: # more than just the id field
|
||||||
|
resp = self._api.put(f"ports/{interface}", json=patch)
|
||||||
|
if not resp.ok:
|
||||||
|
raise ConnectionException(
|
||||||
|
f"set_interface({interface}): ports PUT HTTP {resp.status_code} – {resp.text[:200]}"
|
||||||
|
)
|
||||||
mode = config.get("mode")
|
mode = config.get("mode")
|
||||||
if mode == "trunk":
|
if mode == "trunk":
|
||||||
for vid in config.get("trunk_vlans", []):
|
for vid in config.get("trunk_vlans", []):
|
||||||
@@ -811,6 +834,17 @@ class ProcurveDriver(SwitchDriver):
|
|||||||
self._enter_config_mode()
|
self._enter_config_mode()
|
||||||
try:
|
try:
|
||||||
lines: List[str] = []
|
lines: List[str] = []
|
||||||
|
if "enabled" in config or "description" in config:
|
||||||
|
lines.append(f"interface {interface}")
|
||||||
|
if "enabled" in config:
|
||||||
|
lines.append(" enable" if config["enabled"] else " disable")
|
||||||
|
if "description" in config:
|
||||||
|
desc = config["description"]
|
||||||
|
if desc:
|
||||||
|
lines.append(f' name "{desc}"')
|
||||||
|
else:
|
||||||
|
lines.append(" no name")
|
||||||
|
lines.append("exit")
|
||||||
if mode == "trunk":
|
if mode == "trunk":
|
||||||
for vid in config.get("trunk_vlans", []):
|
for vid in config.get("trunk_vlans", []):
|
||||||
lines.append(f"vlan {vid}")
|
lines.append(f"vlan {vid}")
|
||||||
@@ -1067,28 +1101,75 @@ class ProcurveDriver(SwitchDriver):
|
|||||||
|
|
||||||
ProCurve/Aruba syntax:
|
ProCurve/Aruba syntax:
|
||||||
snmp-server community "public" manager restricted
|
snmp-server community "public" manager restricted
|
||||||
|
|
||||||
|
When connected via REST API, opens a temporary SSH session for the
|
||||||
|
config-mode CLI commands (REST API has no writable SNMP endpoint).
|
||||||
"""
|
"""
|
||||||
|
if self._transport == "api":
|
||||||
|
# REST API cannot run config-mode CLI. Temporarily open SSH.
|
||||||
|
old_api, old_device, old_transport = self._api, self._device, self._transport
|
||||||
|
self._api = None
|
||||||
|
self._device = None
|
||||||
|
self._transport = None
|
||||||
|
|
||||||
|
ssh_error: str = ""
|
||||||
|
for legacy in (False, True):
|
||||||
|
disabled = _SSH_DISABLED_LEGACY if legacy else _SSH_DISABLED_STANDARD
|
||||||
|
try:
|
||||||
|
conn = ConnectHandler(
|
||||||
|
device_type=self.NETMIKO_DEVICE_TYPE,
|
||||||
|
host=self.hostname,
|
||||||
|
username=self.username,
|
||||||
|
password=self.password,
|
||||||
|
secret=self._secret,
|
||||||
|
port=self.port,
|
||||||
|
timeout=self.timeout,
|
||||||
|
disabled_algorithms=disabled,
|
||||||
|
**self.netmiko_optional_args,
|
||||||
|
)
|
||||||
|
self._device = conn
|
||||||
|
self._transport = "ssh_legacy" if legacy else "ssh"
|
||||||
|
break
|
||||||
|
except Exception as exc:
|
||||||
|
ssh_error = str(exc)
|
||||||
|
|
||||||
|
if self._device is None:
|
||||||
|
self._api, self._device, self._transport = old_api, old_device, old_transport
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"output": f"REST transport active; SSH also failed: {ssh_error}",
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
return self._action_fix_snmp_cli()
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
self._device.disconnect()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._api, self._device, self._transport = old_api, old_device, old_transport
|
||||||
|
|
||||||
|
return self._action_fix_snmp_cli()
|
||||||
|
|
||||||
|
def _action_fix_snmp_cli(self) -> Dict:
|
||||||
|
"""Run the SNMP fix via CLI (SSH / Telnet, self._device must be open)."""
|
||||||
lines: list = []
|
lines: list = []
|
||||||
|
|
||||||
self._enter_config_mode()
|
self._enter_config_mode()
|
||||||
|
try:
|
||||||
# Enable SNMP with read-only community 'public'
|
errors = self._apply_config_lines('snmp-server community "public" manager restricted')
|
||||||
# ProCurve: manager = standard access level, restricted = read-only
|
if errors:
|
||||||
self._send_command('snmp-server community "public" manager restricted')
|
lines.append(f"[warn] Config errors: {errors}")
|
||||||
|
return {"success": False, "output": "\n".join(lines)}
|
||||||
lines.append("[config] SNMP community 'public' (manager restricted / read-only) configured.")
|
lines.append("[config] SNMP community 'public' (manager restricted / read-only) configured.")
|
||||||
|
finally:
|
||||||
self._exit_config_mode()
|
self._exit_config_mode()
|
||||||
|
|
||||||
self._save_config()
|
self._save_config()
|
||||||
lines.append("[config] Configuration saved.")
|
lines.append("[config] Configuration saved.")
|
||||||
|
|
||||||
# Verify
|
|
||||||
out = self._send_command("show snmp-server")
|
out = self._send_command("show snmp-server")
|
||||||
success = "public" in out
|
success = "public" in out
|
||||||
if success:
|
lines.append("[ok] SNMP is active with community 'public'." if success
|
||||||
lines.append("[ok] SNMP is active with community 'public'.")
|
else f"[warn] Verification — community not found: {out[:200]}")
|
||||||
else:
|
|
||||||
lines.append(f"[warn] Verification — community not found: {out[:200]}")
|
|
||||||
|
|
||||||
return {"success": success, "output": "\n".join(lines)}
|
return {"success": success, "output": "\n".join(lines)}
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user