python-netgear-switch-library 0.0.post154__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.
- netgear_switch/__init__.py +132 -0
- netgear_switch/_dispatch.py +178 -0
- netgear_switch/_version.py +24 -0
- netgear_switch/aio_api.py +529 -0
- netgear_switch/cli/__init__.py +1 -0
- netgear_switch/cli/capture.py +131 -0
- netgear_switch/cli/context.py +39 -0
- netgear_switch/cli/format.py +201 -0
- netgear_switch/cli/main.py +484 -0
- netgear_switch/cli/resolve.py +108 -0
- netgear_switch/cli/safety.py +71 -0
- netgear_switch/config.py +184 -0
- netgear_switch/errors.py +52 -0
- netgear_switch/http_read.py +174 -0
- netgear_switch/http_write.py +420 -0
- netgear_switch/models.py +156 -0
- netgear_switch/nsdp_read.py +221 -0
- netgear_switch/nsdp_write.py +315 -0
- netgear_switch/protocols/__init__.py +1 -0
- netgear_switch/protocols/http/__init__.py +1 -0
- netgear_switch/protocols/http/crypt.py +29 -0
- netgear_switch/protocols/http/endpoints.py +165 -0
- netgear_switch/protocols/http/forms.py +77 -0
- netgear_switch/protocols/http/parse.py +238 -0
- netgear_switch/protocols/http/session.py +29 -0
- netgear_switch/protocols/nsdp/__init__.py +7 -0
- netgear_switch/protocols/nsdp/auth.py +33 -0
- netgear_switch/protocols/nsdp/client.py +67 -0
- netgear_switch/protocols/nsdp/parsers.py +209 -0
- netgear_switch/protocols/nsdp/protocol.py +201 -0
- netgear_switch/protocols/nsdp/types.py +137 -0
- netgear_switch/protocols/nsdp/write.py +98 -0
- netgear_switch/protocols/snmp/__init__.py +1 -0
- netgear_switch/protocols/snmp/client.py +88 -0
- netgear_switch/protocols/snmp/oids.py +125 -0
- netgear_switch/protocols/snmp/parse.py +777 -0
- netgear_switch/protocols/snmp/write.py +112 -0
- netgear_switch/py.typed +0 -0
- netgear_switch/registry.py +227 -0
- netgear_switch/snmp_read.py +226 -0
- netgear_switch/snmp_write.py +625 -0
- netgear_switch/sync_api.py +557 -0
- netgear_switch/transport/__init__.py +1 -0
- netgear_switch/transport/aio/__init__.py +1 -0
- netgear_switch/transport/aio/nsdp_udp.py +152 -0
- netgear_switch/transport/aio/snmp_pysnmp.py +247 -0
- netgear_switch/transport/http/__init__.py +1 -0
- netgear_switch/transport/http/client.py +217 -0
- netgear_switch/transport/sync/__init__.py +1 -0
- netgear_switch/transport/sync/nsdp_udp.py +109 -0
- netgear_switch/transport/sync/snmp_netsnmp_cli.py +257 -0
- netgear_switch/virtual/__init__.py +8 -0
- netgear_switch/virtual/faces/__init__.py +2 -0
- netgear_switch/virtual/faces/http.py +164 -0
- netgear_switch/virtual/faces/mibview.py +92 -0
- netgear_switch/virtual/faces/nsdp.py +124 -0
- netgear_switch/virtual/faces/snmp.py +412 -0
- netgear_switch/virtual/seed.py +220 -0
- netgear_switch/virtual/server.py +106 -0
- netgear_switch/virtual/state.py +615 -0
- netgear_switch/virtual/web.py +210 -0
- python_netgear_switch_library-0.0.post154.dist-info/METADATA +85 -0
- python_netgear_switch_library-0.0.post154.dist-info/RECORD +66 -0
- python_netgear_switch_library-0.0.post154.dist-info/WHEEL +4 -0
- python_netgear_switch_library-0.0.post154.dist-info/entry_points.txt +2 -0
- python_netgear_switch_library-0.0.post154.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Asynchronous NSDP UDP transport (stdlib asyncio datagram endpoint).
|
|
2
|
+
|
|
3
|
+
Mirrors the sync ``UdpNsdpClient`` but over ``loop.create_datagram_endpoint``.
|
|
4
|
+
The datagram exchange is factored into an injectable ``transceive`` coroutine so
|
|
5
|
+
read/write are unit-testable with a fake exchange (no real UDP), the async
|
|
6
|
+
analogue of the sync client's ``sock_factory`` seam. As with the sync client,
|
|
7
|
+
``client_port=0`` binds an unprivileged ephemeral port for the virtual face.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import socket
|
|
13
|
+
from collections.abc import Awaitable, Callable
|
|
14
|
+
from typing import TYPE_CHECKING
|
|
15
|
+
|
|
16
|
+
from ...protocols.nsdp.client import NsdpError, check_result, read_interface_mac
|
|
17
|
+
from ...protocols.nsdp.protocol import NSDPPacket, Op
|
|
18
|
+
from ...protocols.nsdp.write import build_read_request, build_write_request
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from ...protocols.nsdp.protocol import Tag, TLVEntry
|
|
22
|
+
|
|
23
|
+
Transceive = Callable[..., Awaitable[bytes]]
|
|
24
|
+
|
|
25
|
+
_DUMMY_MAC = b"\x00\x00\x00\x00\x00\x01"
|
|
26
|
+
_BROADCAST_MAC = b"\x00" * 6
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class _OneShotProtocol(asyncio.DatagramProtocol):
|
|
30
|
+
"""Resolves a future with the first datagram (or an error) received."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, future: asyncio.Future[bytes]) -> None:
|
|
33
|
+
self._future = future
|
|
34
|
+
|
|
35
|
+
def datagram_received(self, data: bytes, _addr: object) -> None:
|
|
36
|
+
if not self._future.done():
|
|
37
|
+
self._future.set_result(data)
|
|
38
|
+
|
|
39
|
+
def error_received(self, exc: Exception) -> None:
|
|
40
|
+
if not self._future.done():
|
|
41
|
+
self._future.set_exception(exc)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
async def _udp_transceive(
|
|
45
|
+
payload: bytes,
|
|
46
|
+
addr: tuple[str, int],
|
|
47
|
+
*,
|
|
48
|
+
client_port: int,
|
|
49
|
+
interface: str | None,
|
|
50
|
+
timeout: float,
|
|
51
|
+
) -> bytes:
|
|
52
|
+
loop = asyncio.get_running_loop()
|
|
53
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
54
|
+
try:
|
|
55
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
56
|
+
if interface is not None:
|
|
57
|
+
sock.setsockopt(
|
|
58
|
+
socket.SOL_SOCKET, socket.SO_BINDTODEVICE, interface.encode() + b"\0"
|
|
59
|
+
)
|
|
60
|
+
sock.bind(("", client_port))
|
|
61
|
+
future: asyncio.Future[bytes] = loop.create_future()
|
|
62
|
+
transport, _proto = await loop.create_datagram_endpoint(
|
|
63
|
+
lambda: _OneShotProtocol(future), sock=sock
|
|
64
|
+
)
|
|
65
|
+
except BaseException:
|
|
66
|
+
# setsockopt/bind (or the endpoint handoff itself) failed before
|
|
67
|
+
# create_datagram_endpoint took ownership of the socket on success —
|
|
68
|
+
# nothing else will ever close it, so close it here to avoid an fd
|
|
69
|
+
# leak. Once the try above succeeds, only the transport (below) owns
|
|
70
|
+
# the socket and closes it.
|
|
71
|
+
sock.close()
|
|
72
|
+
raise
|
|
73
|
+
try:
|
|
74
|
+
transport.sendto(payload, addr)
|
|
75
|
+
return await asyncio.wait_for(future, timeout)
|
|
76
|
+
finally:
|
|
77
|
+
transport.close()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class AsyncUdpNsdpClient:
|
|
81
|
+
"""Async NSDP read+write client over UDP for a single switch."""
|
|
82
|
+
|
|
83
|
+
def __init__(
|
|
84
|
+
self,
|
|
85
|
+
host: str,
|
|
86
|
+
*,
|
|
87
|
+
interface: str | None = None,
|
|
88
|
+
client_mac: bytes | None = None,
|
|
89
|
+
client_port: int = 63321,
|
|
90
|
+
server_port: int = 63322,
|
|
91
|
+
timeout: float = 2.0,
|
|
92
|
+
transceive: Transceive = _udp_transceive,
|
|
93
|
+
) -> None:
|
|
94
|
+
self.host = host
|
|
95
|
+
self._interface = interface
|
|
96
|
+
self._client_port = client_port
|
|
97
|
+
self._server_port = server_port
|
|
98
|
+
self._timeout = timeout
|
|
99
|
+
self._transceive = transceive
|
|
100
|
+
self._sequence = 0
|
|
101
|
+
if client_mac is not None:
|
|
102
|
+
self._client_mac = client_mac
|
|
103
|
+
elif interface is not None:
|
|
104
|
+
self._client_mac = read_interface_mac(interface)
|
|
105
|
+
else:
|
|
106
|
+
self._client_mac = _DUMMY_MAC
|
|
107
|
+
|
|
108
|
+
def _next_seq(self) -> int:
|
|
109
|
+
self._sequence = (self._sequence + 1) & 0xFFFF
|
|
110
|
+
return self._sequence
|
|
111
|
+
|
|
112
|
+
async def _exchange(self, request: NSDPPacket) -> NSDPPacket:
|
|
113
|
+
try:
|
|
114
|
+
data = await self._transceive(
|
|
115
|
+
request.encode(),
|
|
116
|
+
(self.host, self._server_port),
|
|
117
|
+
client_port=self._client_port,
|
|
118
|
+
interface=self._interface,
|
|
119
|
+
timeout=self._timeout,
|
|
120
|
+
)
|
|
121
|
+
except TimeoutError as exc:
|
|
122
|
+
raise NsdpError(f"NSDP request to {self.host} timed out") from exc
|
|
123
|
+
try:
|
|
124
|
+
return NSDPPacket.decode(data)
|
|
125
|
+
except ValueError as exc:
|
|
126
|
+
raise NsdpError(
|
|
127
|
+
f"malformed NSDP response from {self.host}: {exc}"
|
|
128
|
+
) from exc
|
|
129
|
+
|
|
130
|
+
async def read(self, tags: list[Tag]) -> NSDPPacket:
|
|
131
|
+
req = build_read_request(
|
|
132
|
+
self._client_mac, _BROADCAST_MAC, self._next_seq(), tags
|
|
133
|
+
)
|
|
134
|
+
resp = await self._exchange(req)
|
|
135
|
+
if resp.op != Op.READ_RESPONSE:
|
|
136
|
+
raise NsdpError(f"expected READ_RESPONSE from {self.host}, got {resp.op}")
|
|
137
|
+
return resp
|
|
138
|
+
|
|
139
|
+
async def write(self, tlvs: list[TLVEntry], *, password: str) -> NSDPPacket:
|
|
140
|
+
req = build_write_request(
|
|
141
|
+
self._client_mac, _BROADCAST_MAC, self._next_seq(), password, tlvs
|
|
142
|
+
)
|
|
143
|
+
resp = await self._exchange(req)
|
|
144
|
+
# Guard the op-code before trusting result (symmetric with read()): a
|
|
145
|
+
# misrouted/duplicate UDP datagram (e.g. a stray READ_RESPONSE with
|
|
146
|
+
# result=0) must not silently pass check_result as a successful write.
|
|
147
|
+
if resp.op != Op.WRITE_RESPONSE:
|
|
148
|
+
raise NsdpError(
|
|
149
|
+
f"expected WRITE_RESPONSE from {self.host}, got {resp.op}"
|
|
150
|
+
)
|
|
151
|
+
check_result(resp)
|
|
152
|
+
return resp
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""Asynchronous SNMP v2c client on pysnmp v7. pysnmp is imported lazily.
|
|
2
|
+
|
|
3
|
+
Value parity: each pysnmp SMI value is normalized to the SAME plain Python type
|
|
4
|
+
the net-snmp CLI client (Task 10) produces — int for integer-family, str for
|
|
5
|
+
text/OID/IP, bytes for non-printable octet strings (Hex-STRING). Task 16's
|
|
6
|
+
sync/async equivalence test compares these values, so they must match.
|
|
7
|
+
|
|
8
|
+
pysnmp ships with no type stubs and is untyped under mypy --strict. Rather than
|
|
9
|
+
a blanket `ignore_missing_imports` for the whole package, `_pysnmp_asyncio()`
|
|
10
|
+
is the single lazy-import seam. It resolves the module dynamically via
|
|
11
|
+
`importlib.import_module` (a plain `str -> ModuleType` call mypy can't follow
|
|
12
|
+
into pysnmp's untyped internals), so no `type: ignore` is needed at all;
|
|
13
|
+
everything downstream of this one seam is deliberately treated as `Any`, and
|
|
14
|
+
the rest of the module is fully typed.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import importlib
|
|
19
|
+
from typing import TYPE_CHECKING, Any
|
|
20
|
+
|
|
21
|
+
from ...protocols.snmp.client import ABSENT_TYPES, SnmpError, SnmpRow
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from ...protocols.snmp.write import SetVarbind
|
|
25
|
+
|
|
26
|
+
Triple = tuple[str, int | str | bytes, str]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _pysnmp_asyncio() -> Any:
|
|
30
|
+
"""Lazily import pysnmp's v3arch asyncio hlapi module."""
|
|
31
|
+
return importlib.import_module("pysnmp.hlapi.v3arch.asyncio")
|
|
32
|
+
|
|
33
|
+
# pysnmp class name -> net-snmp-style type token (parity with the CLI client).
|
|
34
|
+
_TOKEN = {
|
|
35
|
+
"Integer": "INTEGER",
|
|
36
|
+
"Integer32": "INTEGER",
|
|
37
|
+
"Gauge32": "Gauge32",
|
|
38
|
+
"Unsigned32": "Gauge32",
|
|
39
|
+
"Counter32": "Counter32",
|
|
40
|
+
"Counter64": "Counter64",
|
|
41
|
+
"TimeTicks": "Timeticks",
|
|
42
|
+
"IpAddress": "IpAddress",
|
|
43
|
+
"ObjectIdentifier": "OID",
|
|
44
|
+
"ObjectIdentity": "OID",
|
|
45
|
+
}
|
|
46
|
+
_INT_CLASSES = frozenset(
|
|
47
|
+
{"Integer", "Integer32", "Gauge32", "Unsigned32", "Counter32",
|
|
48
|
+
"Counter64", "TimeTicks"}
|
|
49
|
+
)
|
|
50
|
+
_ABSENT_CLASSES = frozenset({"NoSuchObject", "NoSuchInstance", "EndOfMibView"})
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _octet_value(raw: bytes) -> tuple[str | bytes, str]:
|
|
54
|
+
"""Render an octet string as net-snmp does: printable -> STRING, else Hex."""
|
|
55
|
+
if raw == b"" or all(0x20 <= b < 0x7F for b in raw):
|
|
56
|
+
return raw.decode("ascii"), "STRING"
|
|
57
|
+
return raw, "Hex-STRING"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _normalize_varbind(name: Any, value: Any) -> Triple:
|
|
61
|
+
"""Convert a pysnmp (name, value) varbind into a normalized SnmpRow triple."""
|
|
62
|
+
oid = str(name).lstrip(".")
|
|
63
|
+
cls = value.__class__.__name__
|
|
64
|
+
if cls in _ABSENT_CLASSES:
|
|
65
|
+
return oid, "", cls.upper() # e.g. "NOSUCHOBJECT" ∈ ABSENT_TYPES
|
|
66
|
+
if cls in _INT_CLASSES:
|
|
67
|
+
return oid, int(value), _TOKEN[cls]
|
|
68
|
+
if cls == "OctetString":
|
|
69
|
+
norm, token = _octet_value(bytes(value.asOctets()))
|
|
70
|
+
return oid, norm, token
|
|
71
|
+
if cls in ("ObjectIdentifier", "ObjectIdentity"):
|
|
72
|
+
# NOT value.prettyPrint(): hlapi's get_cmd/bulk_walk_cmd auto-resolve
|
|
73
|
+
# an OBJECT IDENTIFIER *value* against their attached MIB view
|
|
74
|
+
# controller, so prettyPrint() can render a well-known prefix
|
|
75
|
+
# symbolically (e.g. "SNMPv2-SMI::enterprises.4526.10.100.14")
|
|
76
|
+
# instead of the plain numeric dotted OID str(value) always gives
|
|
77
|
+
# (confirmed empirically: str(value) == "1.3.6.1.4.1.4526.10.100.14",
|
|
78
|
+
# matching the net-snmp CLI client's numeric ("-On") output exactly).
|
|
79
|
+
return oid, str(value).lstrip("."), "OID"
|
|
80
|
+
if cls == "IpAddress":
|
|
81
|
+
return oid, value.prettyPrint(), "IpAddress"
|
|
82
|
+
return oid, value.prettyPrint(), cls # textual fallback
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _to_set_value(hlapi: Any, vb: SetVarbind) -> Any:
|
|
86
|
+
"""Map a SetVarbind's type letter to the matching pysnmp SMI value object.
|
|
87
|
+
|
|
88
|
+
``s`` and ``x`` both become OctetString (bytes on the wire); ``s`` str
|
|
89
|
+
values are latin-1 encoded (the inverse of the read normalizer). Kept as a
|
|
90
|
+
plain function taking ``hlapi`` so it is unit-testable with a fake module,
|
|
91
|
+
with no live pysnmp import.
|
|
92
|
+
"""
|
|
93
|
+
if vb.type_letter == "i":
|
|
94
|
+
return hlapi.Integer32(int(vb.value))
|
|
95
|
+
if vb.type_letter == "u":
|
|
96
|
+
return hlapi.Gauge32(int(vb.value))
|
|
97
|
+
if vb.type_letter == "a":
|
|
98
|
+
return hlapi.IpAddress(str(vb.value))
|
|
99
|
+
if vb.type_letter in ("s", "x"):
|
|
100
|
+
data = (
|
|
101
|
+
vb.value
|
|
102
|
+
if isinstance(vb.value, bytes)
|
|
103
|
+
else str(vb.value).encode("latin-1")
|
|
104
|
+
)
|
|
105
|
+
return hlapi.OctetString(data)
|
|
106
|
+
raise SnmpError(f"unsupported SET type letter {vb.type_letter!r}")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class PysnmpClient:
|
|
110
|
+
"""Async SNMP v2c read/write client for a single switch."""
|
|
111
|
+
|
|
112
|
+
def __init__(
|
|
113
|
+
self,
|
|
114
|
+
host: str,
|
|
115
|
+
community: str,
|
|
116
|
+
*,
|
|
117
|
+
port: int = 161,
|
|
118
|
+
timeout: float = 2.0,
|
|
119
|
+
retries: int = 1,
|
|
120
|
+
) -> None:
|
|
121
|
+
self.host = host
|
|
122
|
+
self.community = community
|
|
123
|
+
self.port = port
|
|
124
|
+
self.timeout = timeout
|
|
125
|
+
self.retries = retries
|
|
126
|
+
|
|
127
|
+
async def _do_get(self, oids: list[str]) -> list[Triple]:
|
|
128
|
+
hlapi = _pysnmp_asyncio()
|
|
129
|
+
engine = hlapi.SnmpEngine()
|
|
130
|
+
try:
|
|
131
|
+
target = await hlapi.UdpTransportTarget.create(
|
|
132
|
+
(self.host, self.port), timeout=self.timeout, retries=self.retries
|
|
133
|
+
)
|
|
134
|
+
err_ind, err_stat, _idx, binds = await hlapi.get_cmd(
|
|
135
|
+
engine, hlapi.CommunityData(self.community), target,
|
|
136
|
+
hlapi.ContextData(),
|
|
137
|
+
*[hlapi.ObjectType(hlapi.ObjectIdentity(o)) for o in oids],
|
|
138
|
+
)
|
|
139
|
+
if err_ind or err_stat:
|
|
140
|
+
raise SnmpError(f"GET {oids} on {self.host}: {err_ind or err_stat}")
|
|
141
|
+
return [_normalize_varbind(vb[0], vb[1]) for vb in binds]
|
|
142
|
+
finally:
|
|
143
|
+
engine.close_dispatcher()
|
|
144
|
+
|
|
145
|
+
async def _do_walk(self, base_oid: str) -> list[Triple]:
|
|
146
|
+
hlapi = _pysnmp_asyncio()
|
|
147
|
+
engine = hlapi.SnmpEngine()
|
|
148
|
+
rows: list[Triple] = []
|
|
149
|
+
try:
|
|
150
|
+
target = await hlapi.UdpTransportTarget.create(
|
|
151
|
+
(self.host, self.port), timeout=self.timeout, retries=self.retries
|
|
152
|
+
)
|
|
153
|
+
async for err_ind, err_stat, _idx, binds in hlapi.bulk_walk_cmd(
|
|
154
|
+
engine, hlapi.CommunityData(self.community), target,
|
|
155
|
+
hlapi.ContextData(), 0, 25,
|
|
156
|
+
hlapi.ObjectType(hlapi.ObjectIdentity(base_oid)),
|
|
157
|
+
lexicographicMode=False,
|
|
158
|
+
):
|
|
159
|
+
if err_ind or err_stat:
|
|
160
|
+
raise SnmpError(
|
|
161
|
+
f"WALK {base_oid} on {self.host}: {err_ind or err_stat}"
|
|
162
|
+
)
|
|
163
|
+
done = False
|
|
164
|
+
for vb in binds:
|
|
165
|
+
oid, value, typ = _normalize_varbind(vb[0], vb[1])
|
|
166
|
+
if typ == "ENDOFMIBVIEW":
|
|
167
|
+
# Benign terminator (mirrors the sync client's
|
|
168
|
+
# _END_OF_MIB_MARKERS): stop, keep rows so far.
|
|
169
|
+
done = True
|
|
170
|
+
break
|
|
171
|
+
if typ.upper() in ABSENT_TYPES:
|
|
172
|
+
raise SnmpError(
|
|
173
|
+
f"absent OID in pysnmp WALK response: {oid}"
|
|
174
|
+
)
|
|
175
|
+
rows.append((oid, value, typ))
|
|
176
|
+
if done:
|
|
177
|
+
break
|
|
178
|
+
return rows
|
|
179
|
+
finally:
|
|
180
|
+
engine.close_dispatcher()
|
|
181
|
+
|
|
182
|
+
async def get(self, oids: list[str]) -> list[SnmpRow]:
|
|
183
|
+
if not oids:
|
|
184
|
+
return []
|
|
185
|
+
try:
|
|
186
|
+
raw = await self._do_get(oids)
|
|
187
|
+
except SnmpError:
|
|
188
|
+
raise
|
|
189
|
+
except Exception as exc:
|
|
190
|
+
raise SnmpError(f"GET {oids} on {self.host} failed: {exc}") from exc
|
|
191
|
+
rows: list[SnmpRow] = []
|
|
192
|
+
for oid, value, typ in raw:
|
|
193
|
+
if typ.upper() in ABSENT_TYPES:
|
|
194
|
+
raise SnmpError(f"absent OID in pysnmp GET response: {oid}")
|
|
195
|
+
rows.append(SnmpRow(oid, value, typ))
|
|
196
|
+
return rows
|
|
197
|
+
|
|
198
|
+
async def walk(self, base_oid: str) -> list[SnmpRow]:
|
|
199
|
+
try:
|
|
200
|
+
raw = await self._do_walk(base_oid)
|
|
201
|
+
except SnmpError:
|
|
202
|
+
raise
|
|
203
|
+
except Exception as exc:
|
|
204
|
+
raise SnmpError(f"WALK {base_oid} on {self.host} failed: {exc}") from exc
|
|
205
|
+
return [
|
|
206
|
+
SnmpRow(oid, value, typ)
|
|
207
|
+
for oid, value, typ in raw
|
|
208
|
+
if typ.upper() not in ABSENT_TYPES
|
|
209
|
+
]
|
|
210
|
+
|
|
211
|
+
async def _do_set(self, varbinds: list[SetVarbind]) -> None:
|
|
212
|
+
hlapi = _pysnmp_asyncio()
|
|
213
|
+
engine = hlapi.SnmpEngine()
|
|
214
|
+
try:
|
|
215
|
+
target = await hlapi.UdpTransportTarget.create(
|
|
216
|
+
(self.host, self.port), timeout=self.timeout, retries=self.retries
|
|
217
|
+
)
|
|
218
|
+
objects = [
|
|
219
|
+
hlapi.ObjectType(hlapi.ObjectIdentity(vb.oid), _to_set_value(hlapi, vb))
|
|
220
|
+
for vb in varbinds
|
|
221
|
+
]
|
|
222
|
+
err_ind, err_stat, _idx, _binds = await hlapi.set_cmd(
|
|
223
|
+
engine, hlapi.CommunityData(self.community), target,
|
|
224
|
+
hlapi.ContextData(), *objects,
|
|
225
|
+
)
|
|
226
|
+
if err_ind or err_stat:
|
|
227
|
+
raise SnmpError(
|
|
228
|
+
f"SET {[vb.oid for vb in varbinds]} on {self.host}: "
|
|
229
|
+
f"{err_ind or err_stat}"
|
|
230
|
+
)
|
|
231
|
+
finally:
|
|
232
|
+
engine.close_dispatcher()
|
|
233
|
+
|
|
234
|
+
async def set(self, varbind: SetVarbind) -> None:
|
|
235
|
+
await self.set_many([varbind])
|
|
236
|
+
|
|
237
|
+
async def set_many(self, varbinds: list[SetVarbind]) -> None:
|
|
238
|
+
if not varbinds:
|
|
239
|
+
return
|
|
240
|
+
try:
|
|
241
|
+
await self._do_set(varbinds)
|
|
242
|
+
except SnmpError:
|
|
243
|
+
raise
|
|
244
|
+
except Exception as exc:
|
|
245
|
+
raise SnmpError(
|
|
246
|
+
f"SET {[vb.oid for vb in varbinds]} on {self.host} failed: {exc}"
|
|
247
|
+
) from exc
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""httpx-backed HTTP web-UI transport (sync + async)."""
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""httpx-backed web-UI clients implementing the session Protocols.
|
|
2
|
+
|
|
3
|
+
One codebase: all URL/crypto/parse logic lives in the pure ``protocols.http``
|
|
4
|
+
package; only the actual GET/POST differ between the sync ``httpx.Client`` and
|
|
5
|
+
async ``httpx.AsyncClient``. Legacy Plus switches are HTTP-only, so ``base_url``
|
|
6
|
+
is ``http://`` and TLS verification (when a model ever needs https) defaults to
|
|
7
|
+
off for permissive legacy behaviour.
|
|
8
|
+
|
|
9
|
+
httpx is an optional dependency (``[http]`` extra); it is imported at module
|
|
10
|
+
top-level because this module lives under ``transport/http`` and is only ever
|
|
11
|
+
imported lazily by ``_dispatch`` (function-local imports), exactly like the
|
|
12
|
+
SNMP transports — ``import netgear_switch`` never reaches here.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import TYPE_CHECKING
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
|
|
20
|
+
from ...errors import HttpAuthError, HttpError, HttpUnexpectedPageError
|
|
21
|
+
from ...protocols.http.crypt import merge_hash_md5
|
|
22
|
+
from ...protocols.http.endpoints import LoginScheme
|
|
23
|
+
from ...protocols.http.parse import parse_login_rand
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from types import TracebackType
|
|
27
|
+
from typing import Self
|
|
28
|
+
|
|
29
|
+
from ...protocols.http.endpoints import HttpModelSpec
|
|
30
|
+
|
|
31
|
+
_TIMEOUT = 15.0
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _login_body(
|
|
35
|
+
spec: HttpModelSpec, password: str, login_page_html: str
|
|
36
|
+
) -> dict[str, str]:
|
|
37
|
+
"""Build the login POST body for ``spec`` (pure; shared sync+async).
|
|
38
|
+
|
|
39
|
+
MERGE_HASH_CGI/GAMBIT hash the password with the page ``rand`` nonce;
|
|
40
|
+
CHEETAH_FORM posts the plaintext password. Raises ``HttpUnexpectedPageError``
|
|
41
|
+
if a required ``rand`` nonce is missing from the login page.
|
|
42
|
+
"""
|
|
43
|
+
if spec.scheme is LoginScheme.CHEETAH_FORM:
|
|
44
|
+
return {spec.password_field: password}
|
|
45
|
+
rand = parse_login_rand(login_page_html) if spec.needs_rand else None
|
|
46
|
+
if spec.needs_rand and not rand:
|
|
47
|
+
raise HttpUnexpectedPageError(
|
|
48
|
+
f"no login 'rand' nonce on {spec.login_path} — not a {spec.model_key}?"
|
|
49
|
+
)
|
|
50
|
+
hashed = merge_hash_md5(password, rand or "")
|
|
51
|
+
return {spec.password_field: hashed}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _check_authed(spec: HttpModelSpec, cookies: httpx.Cookies) -> None:
|
|
55
|
+
if spec.cookie_name not in cookies:
|
|
56
|
+
raise HttpAuthError(
|
|
57
|
+
f"web-UI login failed for {spec.model_key} — no {spec.cookie_name} cookie "
|
|
58
|
+
"(check password, or switch may be locked out)"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _validate_response(
|
|
63
|
+
resp: httpx.Response, *, context: str, path: str | None = None
|
|
64
|
+
) -> None:
|
|
65
|
+
"""Raise on an HTTP-error status, or (if ``path`` given) a lost session.
|
|
66
|
+
|
|
67
|
+
Pure; shared by every sync/async GET/POST call site so status-code and
|
|
68
|
+
stale-session handling cannot drift between the two codebases.
|
|
69
|
+
|
|
70
|
+
``context`` names the request for the status-code error (e.g. ``"GET
|
|
71
|
+
/login.cgi"``). ``path`` is only passed by mid-session reads that should
|
|
72
|
+
also detect the web-UI silently redirecting back to the login page.
|
|
73
|
+
"""
|
|
74
|
+
if resp.status_code >= 400:
|
|
75
|
+
raise HttpError(f"{context} returned HTTP {resp.status_code}")
|
|
76
|
+
if path is not None and "redirect to login" in resp.text.lower():
|
|
77
|
+
raise HttpAuthError(f"session lost fetching {path}")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class HttpClient:
|
|
81
|
+
"""Synchronous httpx web-UI session (implements ``HttpSession``)."""
|
|
82
|
+
|
|
83
|
+
def __init__(
|
|
84
|
+
self,
|
|
85
|
+
host: str,
|
|
86
|
+
password: str,
|
|
87
|
+
spec: HttpModelSpec,
|
|
88
|
+
*,
|
|
89
|
+
verify_tls: bool = False,
|
|
90
|
+
transport: httpx.MockTransport | None = None,
|
|
91
|
+
) -> None:
|
|
92
|
+
self._spec = spec
|
|
93
|
+
self._password = password
|
|
94
|
+
self._client = httpx.Client(
|
|
95
|
+
base_url=f"http://{host}",
|
|
96
|
+
timeout=_TIMEOUT,
|
|
97
|
+
verify=verify_tls,
|
|
98
|
+
transport=transport,
|
|
99
|
+
follow_redirects=True,
|
|
100
|
+
)
|
|
101
|
+
self._logged_in = False
|
|
102
|
+
|
|
103
|
+
def __enter__(self) -> Self:
|
|
104
|
+
return self
|
|
105
|
+
|
|
106
|
+
def __exit__(
|
|
107
|
+
self,
|
|
108
|
+
exc_type: type[BaseException] | None,
|
|
109
|
+
exc: BaseException | None,
|
|
110
|
+
tb: TracebackType | None,
|
|
111
|
+
) -> None:
|
|
112
|
+
self.close()
|
|
113
|
+
|
|
114
|
+
def login(self) -> None:
|
|
115
|
+
try:
|
|
116
|
+
page = self._client.get(self._spec.login_path)
|
|
117
|
+
_validate_response(page, context=f"GET {self._spec.login_path}")
|
|
118
|
+
body = _login_body(self._spec, self._password, page.text)
|
|
119
|
+
resp = self._client.post(self._spec.login_path, data=body)
|
|
120
|
+
_validate_response(resp, context=f"POST {self._spec.login_path}")
|
|
121
|
+
except httpx.HTTPError as exc:
|
|
122
|
+
raise HttpError(f"web-UI login transport error: {exc}") from exc
|
|
123
|
+
_check_authed(self._spec, self._client.cookies)
|
|
124
|
+
self._logged_in = True
|
|
125
|
+
|
|
126
|
+
def get_page(self, path: str) -> str:
|
|
127
|
+
if not self._logged_in:
|
|
128
|
+
self.login()
|
|
129
|
+
try:
|
|
130
|
+
resp = self._client.get(path)
|
|
131
|
+
except httpx.HTTPError as exc:
|
|
132
|
+
raise HttpError(f"GET {path} transport error: {exc}") from exc
|
|
133
|
+
_validate_response(resp, context=f"GET {path}", path=path)
|
|
134
|
+
return resp.text
|
|
135
|
+
|
|
136
|
+
def post_form(self, path: str, data: dict[str, str]) -> str:
|
|
137
|
+
if not self._logged_in:
|
|
138
|
+
self.login()
|
|
139
|
+
try:
|
|
140
|
+
resp = self._client.post(path, data=data)
|
|
141
|
+
except httpx.HTTPError as exc:
|
|
142
|
+
raise HttpError(f"POST {path} transport error: {exc}") from exc
|
|
143
|
+
_validate_response(resp, context=f"POST {path}")
|
|
144
|
+
return resp.text
|
|
145
|
+
|
|
146
|
+
def close(self) -> None:
|
|
147
|
+
self._client.close()
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class AsyncHttpClient:
|
|
151
|
+
"""Asynchronous httpx web-UI session (implements ``AsyncHttpSession``)."""
|
|
152
|
+
|
|
153
|
+
def __init__(
|
|
154
|
+
self,
|
|
155
|
+
host: str,
|
|
156
|
+
password: str,
|
|
157
|
+
spec: HttpModelSpec,
|
|
158
|
+
*,
|
|
159
|
+
verify_tls: bool = False,
|
|
160
|
+
transport: httpx.MockTransport | None = None,
|
|
161
|
+
) -> None:
|
|
162
|
+
self._spec = spec
|
|
163
|
+
self._password = password
|
|
164
|
+
self._client = httpx.AsyncClient(
|
|
165
|
+
base_url=f"http://{host}",
|
|
166
|
+
timeout=_TIMEOUT,
|
|
167
|
+
verify=verify_tls,
|
|
168
|
+
transport=transport,
|
|
169
|
+
follow_redirects=True,
|
|
170
|
+
)
|
|
171
|
+
self._logged_in = False
|
|
172
|
+
|
|
173
|
+
async def __aenter__(self) -> Self:
|
|
174
|
+
return self
|
|
175
|
+
|
|
176
|
+
async def __aexit__(
|
|
177
|
+
self,
|
|
178
|
+
exc_type: type[BaseException] | None,
|
|
179
|
+
exc: BaseException | None,
|
|
180
|
+
tb: TracebackType | None,
|
|
181
|
+
) -> None:
|
|
182
|
+
await self.aclose()
|
|
183
|
+
|
|
184
|
+
async def login(self) -> None:
|
|
185
|
+
try:
|
|
186
|
+
page = await self._client.get(self._spec.login_path)
|
|
187
|
+
_validate_response(page, context=f"GET {self._spec.login_path}")
|
|
188
|
+
body = _login_body(self._spec, self._password, page.text)
|
|
189
|
+
resp = await self._client.post(self._spec.login_path, data=body)
|
|
190
|
+
_validate_response(resp, context=f"POST {self._spec.login_path}")
|
|
191
|
+
except httpx.HTTPError as exc:
|
|
192
|
+
raise HttpError(f"web-UI login transport error: {exc}") from exc
|
|
193
|
+
_check_authed(self._spec, self._client.cookies)
|
|
194
|
+
self._logged_in = True
|
|
195
|
+
|
|
196
|
+
async def get_page(self, path: str) -> str:
|
|
197
|
+
if not self._logged_in:
|
|
198
|
+
await self.login()
|
|
199
|
+
try:
|
|
200
|
+
resp = await self._client.get(path)
|
|
201
|
+
except httpx.HTTPError as exc:
|
|
202
|
+
raise HttpError(f"GET {path} transport error: {exc}") from exc
|
|
203
|
+
_validate_response(resp, context=f"GET {path}", path=path)
|
|
204
|
+
return resp.text
|
|
205
|
+
|
|
206
|
+
async def post_form(self, path: str, data: dict[str, str]) -> str:
|
|
207
|
+
if not self._logged_in:
|
|
208
|
+
await self.login()
|
|
209
|
+
try:
|
|
210
|
+
resp = await self._client.post(path, data=data)
|
|
211
|
+
except httpx.HTTPError as exc:
|
|
212
|
+
raise HttpError(f"POST {path} transport error: {exc}") from exc
|
|
213
|
+
_validate_response(resp, context=f"POST {path}")
|
|
214
|
+
return resp.text
|
|
215
|
+
|
|
216
|
+
async def aclose(self) -> None:
|
|
217
|
+
await self._client.aclose()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Synchronous SNMP transport implementations."""
|