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,109 @@
|
|
|
1
|
+
"""Synchronous NSDP UDP transport (stdlib sockets only).
|
|
2
|
+
|
|
3
|
+
Binds a UDP client port and exchanges one request/response datagram with the
|
|
4
|
+
switch over unicast (the ``query_ip`` pattern — preferred over broadcast
|
|
5
|
+
discovery for a known host). ``client_port`` defaults to the real NSDP client
|
|
6
|
+
port 63321, but the virtual face lets tests pass ``client_port=0`` to bind an
|
|
7
|
+
unprivileged ephemeral port on loopback (so no root/CAP_NET_BIND_SERVICE and no
|
|
8
|
+
SO_BINDTODEVICE are needed under test). Errors (timeout / malformed / bad
|
|
9
|
+
password) surface as ``NsdpError``, never silently.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import socket
|
|
14
|
+
from typing import TYPE_CHECKING, Any
|
|
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 collections.abc import Callable
|
|
22
|
+
|
|
23
|
+
from ...protocols.nsdp.protocol import Tag, TLVEntry
|
|
24
|
+
|
|
25
|
+
_DUMMY_MAC = b"\x00\x00\x00\x00\x00\x01"
|
|
26
|
+
_BROADCAST_MAC = b"\x00" * 6
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class UdpNsdpClient:
|
|
30
|
+
"""Sync NSDP read+write client over UDP for a single switch."""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
host: str,
|
|
35
|
+
*,
|
|
36
|
+
interface: str | None = None,
|
|
37
|
+
client_mac: bytes | None = None,
|
|
38
|
+
client_port: int = 63321,
|
|
39
|
+
server_port: int = 63322,
|
|
40
|
+
timeout: float = 2.0,
|
|
41
|
+
sock_factory: Callable[..., Any] = socket.socket,
|
|
42
|
+
) -> None:
|
|
43
|
+
self.host = host
|
|
44
|
+
self._interface = interface
|
|
45
|
+
self._client_port = client_port
|
|
46
|
+
self._server_port = server_port
|
|
47
|
+
self._timeout = timeout
|
|
48
|
+
self._sock_factory = sock_factory
|
|
49
|
+
self._sequence = 0
|
|
50
|
+
if client_mac is not None:
|
|
51
|
+
self._client_mac = client_mac
|
|
52
|
+
elif interface is not None:
|
|
53
|
+
self._client_mac = read_interface_mac(interface)
|
|
54
|
+
else:
|
|
55
|
+
self._client_mac = _DUMMY_MAC
|
|
56
|
+
|
|
57
|
+
def _next_seq(self) -> int:
|
|
58
|
+
self._sequence = (self._sequence + 1) & 0xFFFF
|
|
59
|
+
return self._sequence
|
|
60
|
+
|
|
61
|
+
def _exchange(self, request: NSDPPacket) -> NSDPPacket:
|
|
62
|
+
sock = self._sock_factory(socket.AF_INET, socket.SOCK_DGRAM)
|
|
63
|
+
try:
|
|
64
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
65
|
+
if self._interface is not None:
|
|
66
|
+
sock.setsockopt(
|
|
67
|
+
socket.SOL_SOCKET,
|
|
68
|
+
socket.SO_BINDTODEVICE,
|
|
69
|
+
self._interface.encode() + b"\0",
|
|
70
|
+
)
|
|
71
|
+
sock.bind(("", self._client_port))
|
|
72
|
+
sock.settimeout(self._timeout)
|
|
73
|
+
sock.sendto(request.encode(), (self.host, self._server_port))
|
|
74
|
+
try:
|
|
75
|
+
data, _addr = sock.recvfrom(4096)
|
|
76
|
+
except TimeoutError as exc:
|
|
77
|
+
raise NsdpError(f"NSDP request to {self.host} timed out") from exc
|
|
78
|
+
try:
|
|
79
|
+
return NSDPPacket.decode(data)
|
|
80
|
+
except ValueError as exc:
|
|
81
|
+
raise NsdpError(
|
|
82
|
+
f"malformed NSDP response from {self.host}: {exc}"
|
|
83
|
+
) from exc
|
|
84
|
+
finally:
|
|
85
|
+
sock.close()
|
|
86
|
+
|
|
87
|
+
def read(self, tags: list[Tag]) -> NSDPPacket:
|
|
88
|
+
req = build_read_request(
|
|
89
|
+
self._client_mac, _BROADCAST_MAC, self._next_seq(), tags
|
|
90
|
+
)
|
|
91
|
+
resp = self._exchange(req)
|
|
92
|
+
if resp.op != Op.READ_RESPONSE:
|
|
93
|
+
raise NsdpError(f"expected READ_RESPONSE from {self.host}, got {resp.op}")
|
|
94
|
+
return resp
|
|
95
|
+
|
|
96
|
+
def write(self, tlvs: list[TLVEntry], *, password: str) -> NSDPPacket:
|
|
97
|
+
req = build_write_request(
|
|
98
|
+
self._client_mac, _BROADCAST_MAC, self._next_seq(), password, tlvs
|
|
99
|
+
)
|
|
100
|
+
resp = self._exchange(req)
|
|
101
|
+
# Guard the op-code before trusting result (symmetric with read()): a
|
|
102
|
+
# misrouted/duplicate UDP datagram (e.g. a stray READ_RESPONSE with
|
|
103
|
+
# result=0) must not silently pass check_result as a successful write.
|
|
104
|
+
if resp.op != Op.WRITE_RESPONSE:
|
|
105
|
+
raise NsdpError(
|
|
106
|
+
f"expected WRITE_RESPONSE from {self.host}, got {resp.op}"
|
|
107
|
+
)
|
|
108
|
+
check_result(resp)
|
|
109
|
+
return resp
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""Synchronous SNMP v2c client over the net-snmp CLI tools (subprocess).
|
|
2
|
+
|
|
3
|
+
No Python SNMP package is used. The net-snmp binaries (snmpget/snmpbulkwalk)
|
|
4
|
+
are a system requirement — install the OS `snmp` package
|
|
5
|
+
(`apt-get install -y snmp`). Args are passed as a list; shell is never used.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
from typing import TYPE_CHECKING, Any, Protocol
|
|
13
|
+
|
|
14
|
+
from ...protocols.snmp.client import SnmpError, SnmpRow
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from collections.abc import Callable, Sequence
|
|
18
|
+
|
|
19
|
+
from ...protocols.snmp.write import SetVarbind
|
|
20
|
+
|
|
21
|
+
# -On numeric OIDs, -Oe enums-as-numbers, -OU no units, -Ln no stderr logging.
|
|
22
|
+
_OUTPUT_FLAGS = ("-On", "-Oe", "-OU", "-Ln")
|
|
23
|
+
|
|
24
|
+
# Type tokens net-snmp prints for integer-family values.
|
|
25
|
+
_INT_TYPES = frozenset(
|
|
26
|
+
{
|
|
27
|
+
"INTEGER",
|
|
28
|
+
"Integer32",
|
|
29
|
+
"Gauge32",
|
|
30
|
+
"Gauge",
|
|
31
|
+
"Unsigned32",
|
|
32
|
+
"Counter32",
|
|
33
|
+
"Counter64",
|
|
34
|
+
"Counter",
|
|
35
|
+
}
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
_ABSENT_MARKERS = (
|
|
39
|
+
"no such object",
|
|
40
|
+
"no such instance",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
# Benign terminator snmpbulkwalk appends once a walk reaches the end of the
|
|
44
|
+
# agent's MIB tree. Not an error: skip the line and return the rows already
|
|
45
|
+
# parsed so far.
|
|
46
|
+
_END_OF_MIB_MARKERS = (
|
|
47
|
+
"no more variables left in this mib view",
|
|
48
|
+
"past the end of the mib tree",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
_TIMETICKS_RE = re.compile(r"\((\d+)\)")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _format_set_value(vb: SetVarbind) -> str:
|
|
55
|
+
"""Render a SetVarbind value as the string snmpset expects for its type.
|
|
56
|
+
|
|
57
|
+
``x`` (hex/octets) is emitted as lowercase hex digits; every other type is
|
|
58
|
+
stringified directly (net-snmp parses ``i``/``u``/``s``/``a`` from text).
|
|
59
|
+
"""
|
|
60
|
+
if vb.type_letter == "x":
|
|
61
|
+
data = (
|
|
62
|
+
vb.value
|
|
63
|
+
if isinstance(vb.value, bytes)
|
|
64
|
+
else str(vb.value).encode("latin-1")
|
|
65
|
+
)
|
|
66
|
+
return data.hex()
|
|
67
|
+
return str(vb.value)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class _CompletedProcess(Protocol):
|
|
71
|
+
returncode: int
|
|
72
|
+
stdout: str
|
|
73
|
+
stderr: str
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _which(binary: str) -> str:
|
|
77
|
+
"""Return the resolved path to a net-snmp binary or raise SnmpError."""
|
|
78
|
+
path = shutil.which(binary)
|
|
79
|
+
if path is None:
|
|
80
|
+
raise SnmpError(
|
|
81
|
+
f"net-snmp not installed: {binary!r} is not on PATH. "
|
|
82
|
+
"Install the `snmp` package (e.g. `apt-get install -y snmp`)."
|
|
83
|
+
)
|
|
84
|
+
return path
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _normalize(snmp_type: str, value: str) -> int | str | bytes:
|
|
88
|
+
"""Normalize one net-snmp scalar value to a plain Python value.
|
|
89
|
+
|
|
90
|
+
Mirrors the pysnmp client (Task 11) so SnmpRow values are transport-equal.
|
|
91
|
+
"""
|
|
92
|
+
if snmp_type in _INT_TYPES:
|
|
93
|
+
try:
|
|
94
|
+
return int(value)
|
|
95
|
+
except ValueError as exc:
|
|
96
|
+
raise SnmpError(f"non-integer {snmp_type} value {value!r}") from exc
|
|
97
|
+
if snmp_type == "Timeticks":
|
|
98
|
+
m = _TIMETICKS_RE.search(value)
|
|
99
|
+
if m is None:
|
|
100
|
+
raise SnmpError(f"unparsable Timeticks value {value!r}")
|
|
101
|
+
return int(m.group(1))
|
|
102
|
+
if snmp_type == "STRING":
|
|
103
|
+
if len(value) >= 2 and value[0] == '"' and value[-1] == '"':
|
|
104
|
+
return value[1:-1]
|
|
105
|
+
return value
|
|
106
|
+
if snmp_type == "OID":
|
|
107
|
+
return value.lstrip(".")
|
|
108
|
+
# IpAddress and any other textual type: plain string.
|
|
109
|
+
return value
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _split_typed(rest: str) -> tuple[str, str] | None:
|
|
113
|
+
"""If `rest` looks like `<TYPE>: <value>` (or `<TYPE>:` with no value),
|
|
114
|
+
return `(snmp_type, value)`. Otherwise return None.
|
|
115
|
+
|
|
116
|
+
This is checked *before* any marker text so a STRING value that merely
|
|
117
|
+
contains marker words (e.g. `STRING: "no such object test"`) is always
|
|
118
|
+
parsed as a normal typed value, never mistaken for a marker line.
|
|
119
|
+
"""
|
|
120
|
+
if ": " in rest:
|
|
121
|
+
snmp_type, value = rest.split(": ", 1)
|
|
122
|
+
return snmp_type.strip(), value.strip()
|
|
123
|
+
if rest.endswith(":"):
|
|
124
|
+
return rest[:-1].strip(), ""
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def parse_netsnmp_lines(text: str) -> list[SnmpRow]:
|
|
129
|
+
"""Parse `snmpget`/`snmpbulkwalk` output (-On -Oe -OU -Ln) into SnmpRows.
|
|
130
|
+
|
|
131
|
+
Raises SnmpError on a "No Such Object/Instance" line — an absent OID is
|
|
132
|
+
surfaced early, never returned as an empty/None row. The benign
|
|
133
|
+
"No more variables left in this MIB View .../ past the end of the MIB
|
|
134
|
+
tree" terminator that snmpbulkwalk appends at the end of a successful
|
|
135
|
+
walk is skipped rather than treated as an error, so previously parsed
|
|
136
|
+
rows are still returned. Multi-line Hex-STRING continuations are joined
|
|
137
|
+
into one bytes value.
|
|
138
|
+
"""
|
|
139
|
+
rows: list[SnmpRow] = []
|
|
140
|
+
pending_oid: str | None = None
|
|
141
|
+
pending_hex: list[str] = []
|
|
142
|
+
|
|
143
|
+
def flush_hex() -> None:
|
|
144
|
+
nonlocal pending_oid
|
|
145
|
+
if pending_oid is not None:
|
|
146
|
+
data = bytes(
|
|
147
|
+
int(tok, 16) for chunk in pending_hex for tok in chunk.split()
|
|
148
|
+
)
|
|
149
|
+
rows.append(SnmpRow(pending_oid, data, "Hex-STRING"))
|
|
150
|
+
pending_oid = None
|
|
151
|
+
pending_hex.clear()
|
|
152
|
+
|
|
153
|
+
for raw in text.splitlines():
|
|
154
|
+
if not raw.strip():
|
|
155
|
+
continue
|
|
156
|
+
if " = " not in raw:
|
|
157
|
+
if pending_oid is not None: # Hex-STRING continuation line
|
|
158
|
+
pending_hex.append(raw.strip())
|
|
159
|
+
continue
|
|
160
|
+
flush_hex()
|
|
161
|
+
oid_part, rest = raw.split(" = ", 1)
|
|
162
|
+
oid = oid_part.strip().lstrip(".")
|
|
163
|
+
rest = rest.strip()
|
|
164
|
+
if rest in ('""', ""):
|
|
165
|
+
rows.append(SnmpRow(oid, "", "STRING"))
|
|
166
|
+
continue
|
|
167
|
+
typed = _split_typed(rest)
|
|
168
|
+
if typed is not None:
|
|
169
|
+
snmp_type, value = typed
|
|
170
|
+
if snmp_type == "Hex-STRING":
|
|
171
|
+
pending_oid = oid
|
|
172
|
+
pending_hex = [value]
|
|
173
|
+
continue
|
|
174
|
+
rows.append(SnmpRow(oid, _normalize(snmp_type, value), snmp_type))
|
|
175
|
+
continue
|
|
176
|
+
rest_lower = rest.lower()
|
|
177
|
+
if any(marker in rest_lower for marker in _END_OF_MIB_MARKERS):
|
|
178
|
+
continue
|
|
179
|
+
if any(marker in rest_lower for marker in _ABSENT_MARKERS):
|
|
180
|
+
raise SnmpError(f"absent OID in net-snmp output: {oid} = {rest}")
|
|
181
|
+
raise SnmpError(f"unrecognized net-snmp output line: {oid} = {rest}")
|
|
182
|
+
flush_hex()
|
|
183
|
+
return rows
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class NetsnmpCliClient:
|
|
187
|
+
"""Read-only sync SNMP client shelling out to net-snmp CLI tools."""
|
|
188
|
+
|
|
189
|
+
def __init__(
|
|
190
|
+
self,
|
|
191
|
+
host: str,
|
|
192
|
+
community: str,
|
|
193
|
+
*,
|
|
194
|
+
timeout: int = 10,
|
|
195
|
+
retries: int = 1,
|
|
196
|
+
runner: Callable[..., _CompletedProcess] = subprocess.run,
|
|
197
|
+
) -> None:
|
|
198
|
+
self.host = host
|
|
199
|
+
self.community = community
|
|
200
|
+
self.timeout = timeout
|
|
201
|
+
self.retries = retries
|
|
202
|
+
self._runner = runner
|
|
203
|
+
|
|
204
|
+
def _base_args(self, binary: str) -> list[str]:
|
|
205
|
+
return [
|
|
206
|
+
_which(binary),
|
|
207
|
+
"-v2c",
|
|
208
|
+
"-c",
|
|
209
|
+
self.community,
|
|
210
|
+
*_OUTPUT_FLAGS,
|
|
211
|
+
"-t",
|
|
212
|
+
str(self.timeout),
|
|
213
|
+
"-r",
|
|
214
|
+
str(self.retries),
|
|
215
|
+
]
|
|
216
|
+
|
|
217
|
+
def get(self, oids: list[str]) -> list[SnmpRow]:
|
|
218
|
+
if not oids:
|
|
219
|
+
return []
|
|
220
|
+
argv = [*self._base_args("snmpget"), self.host, *oids]
|
|
221
|
+
return self._invoke(argv)
|
|
222
|
+
|
|
223
|
+
def walk(self, base_oid: str) -> list[SnmpRow]:
|
|
224
|
+
argv = [*self._base_args("snmpbulkwalk"), self.host, base_oid]
|
|
225
|
+
return self._invoke(argv)
|
|
226
|
+
|
|
227
|
+
def set(self, varbind: SetVarbind) -> None:
|
|
228
|
+
self.set_many([varbind])
|
|
229
|
+
|
|
230
|
+
def set_many(self, varbinds: list[SetVarbind]) -> None:
|
|
231
|
+
if not varbinds:
|
|
232
|
+
return
|
|
233
|
+
triples: list[str] = []
|
|
234
|
+
for vb in varbinds:
|
|
235
|
+
triples += [vb.oid, vb.type_letter, _format_set_value(vb)]
|
|
236
|
+
argv = [*self._base_args("snmpset"), self.host, *triples]
|
|
237
|
+
# _invoke raises SnmpError on non-zero exit or any stderr (commitFailed,
|
|
238
|
+
# noSuchName, wrong type). The echoed varbinds it parses are discarded.
|
|
239
|
+
self._invoke(argv)
|
|
240
|
+
|
|
241
|
+
def _invoke(self, argv: Sequence[str]) -> list[SnmpRow]:
|
|
242
|
+
kwargs: dict[str, Any] = {
|
|
243
|
+
"capture_output": True,
|
|
244
|
+
"text": True,
|
|
245
|
+
"check": False,
|
|
246
|
+
}
|
|
247
|
+
try:
|
|
248
|
+
proc = self._runner(list(argv), **kwargs)
|
|
249
|
+
except OSError as exc: # binary vanished between _which and run
|
|
250
|
+
raise SnmpError(f"failed to run {argv[0]!r}: {exc}") from exc
|
|
251
|
+
stderr = (proc.stderr or "").strip()
|
|
252
|
+
if proc.returncode != 0 or stderr:
|
|
253
|
+
raise SnmpError(
|
|
254
|
+
f"{argv[0]} exited {proc.returncode} for {self.host}: "
|
|
255
|
+
f"{stderr or 'unknown error'}"
|
|
256
|
+
)
|
|
257
|
+
return parse_netsnmp_lines(proc.stdout)
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Virtual switch: an in-memory device state plus protocol "faces" onto it.
|
|
2
|
+
|
|
3
|
+
``VirtualSwitchState`` (state.py) is the one authoritative in-memory device
|
|
4
|
+
state; ``seed.py`` hand-authors a realistic instance for a given model; the
|
|
5
|
+
``faces`` subpackage (Task 15+) serves that state over a real protocol (e.g.
|
|
6
|
+
SNMP) so both transport clients can be tested against it end-to-end.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""A real ``http.server`` web-UI face serving a ``VirtualSwitchState``.
|
|
2
|
+
|
|
3
|
+
Binds a ``ThreadingHTTPServer`` to an ephemeral TCP port on ``127.0.0.1`` and
|
|
4
|
+
serves the login CGI + read/write CGI pages from device state via
|
|
5
|
+
``virtual.web``. Both httpx transport clients (sync + async) are exercised
|
|
6
|
+
end-to-end against it with no hardware.
|
|
7
|
+
|
|
8
|
+
A real switch never fabricates a 200 for a capability it doesn't have, so
|
|
9
|
+
this face 404s any request whose path is not one of this model's *populated*
|
|
10
|
+
``HttpModelSpec`` fields, before ever calling into ``virtual.web`` — that
|
|
11
|
+
module's ``render_page`` has a deliberately permissive catch-all (see its
|
|
12
|
+
docstring) that is only safe to reach for a path this spec actually
|
|
13
|
+
advertises.
|
|
14
|
+
|
|
15
|
+
Teardown is deterministic: ``stop()`` calls ``shutdown()`` (unblocks
|
|
16
|
+
``serve_forever``), joins the server thread, then ``server_close()`` closes the
|
|
17
|
+
listening socket — so nothing leaks under ``-W error::ResourceWarning``.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import dataclasses
|
|
22
|
+
import threading
|
|
23
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
24
|
+
from typing import TYPE_CHECKING
|
|
25
|
+
from urllib.parse import parse_qs
|
|
26
|
+
|
|
27
|
+
from ...protocols.http.crypt import merge_hash_md5
|
|
28
|
+
from ...protocols.http.endpoints import HttpModelSpec, LoginScheme
|
|
29
|
+
from .. import web
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
from ..state import VirtualSwitchState
|
|
33
|
+
|
|
34
|
+
# Every path-shaped field an HttpModelSpec may populate, other than
|
|
35
|
+
# login_path (handled separately as the login handshake). A model that
|
|
36
|
+
# leaves one of these None does not serve that endpoint at all.
|
|
37
|
+
#
|
|
38
|
+
# Derived from the dataclass itself (rather than hand-maintained) so a future
|
|
39
|
+
# spec field ending in "_path" is picked up automatically instead of silently
|
|
40
|
+
# 404ing forever; login_path is filtered back out since it is handled by the
|
|
41
|
+
# login handshake, not the known-path gate.
|
|
42
|
+
_PATH_FIELDS: tuple[str, ...] = tuple(
|
|
43
|
+
f.name
|
|
44
|
+
for f in dataclasses.fields(HttpModelSpec)
|
|
45
|
+
if f.name.endswith("_path") and f.name != "login_path"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _known_paths(spec: HttpModelSpec) -> set[str]:
|
|
50
|
+
"""The set of paths ``spec`` actually serves (populated fields only)."""
|
|
51
|
+
return {
|
|
52
|
+
value
|
|
53
|
+
for name in _PATH_FIELDS
|
|
54
|
+
if (value := getattr(spec, name)) is not None
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class VirtualHttpFace:
|
|
59
|
+
"""A ``ThreadingHTTPServer`` web-UI face serving a ``VirtualSwitchState``."""
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
state: VirtualSwitchState,
|
|
64
|
+
spec: HttpModelSpec,
|
|
65
|
+
*,
|
|
66
|
+
host: str = "127.0.0.1",
|
|
67
|
+
password: str = "password",
|
|
68
|
+
rand: str = "1234",
|
|
69
|
+
) -> None:
|
|
70
|
+
self.state = state
|
|
71
|
+
self.spec = spec
|
|
72
|
+
self.host = host
|
|
73
|
+
self.password = password
|
|
74
|
+
self.rand = rand
|
|
75
|
+
self._known_paths = _known_paths(spec)
|
|
76
|
+
self._server: ThreadingHTTPServer | None = None
|
|
77
|
+
self._thread: threading.Thread | None = None
|
|
78
|
+
self._cookie = f"{spec.cookie_name}=virtualsid"
|
|
79
|
+
# ThreadingHTTPServer runs one thread per request; do_GET/do_POST
|
|
80
|
+
# mutate shared VirtualSwitchState via web.render_page/apply_form
|
|
81
|
+
# with no lock of their own, so two overlapping requests (e.g. a
|
|
82
|
+
# sync and an async client hitting the same VirtualSwitch) would
|
|
83
|
+
# race. Serialize just the render/apply critical section on this
|
|
84
|
+
# single lock rather than the whole request.
|
|
85
|
+
self._lock = threading.Lock()
|
|
86
|
+
|
|
87
|
+
def start(self) -> int:
|
|
88
|
+
face = self
|
|
89
|
+
|
|
90
|
+
class Handler(BaseHTTPRequestHandler):
|
|
91
|
+
def log_message(self, *_args: object) -> None: # silence stderr
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
def _body(self) -> dict[str, str]:
|
|
95
|
+
length = int(self.headers.get("Content-Length", "0"))
|
|
96
|
+
raw = self.rfile.read(length).decode() if length else ""
|
|
97
|
+
return {k: v[0] for k, v in parse_qs(raw).items()}
|
|
98
|
+
|
|
99
|
+
def _send(
|
|
100
|
+
self, text: str, status: int = 200, *, cookie: bool = False
|
|
101
|
+
) -> None:
|
|
102
|
+
data = text.encode()
|
|
103
|
+
self.send_response(status)
|
|
104
|
+
self.send_header("Content-Type", "text/html")
|
|
105
|
+
self.send_header("Content-Length", str(len(data)))
|
|
106
|
+
if cookie:
|
|
107
|
+
self.send_header("Set-Cookie", f"{face._cookie}; path=/")
|
|
108
|
+
self.end_headers()
|
|
109
|
+
self.wfile.write(data)
|
|
110
|
+
|
|
111
|
+
def do_GET(self) -> None:
|
|
112
|
+
path = self.path.split("?", 1)[0]
|
|
113
|
+
if path == face.spec.login_path:
|
|
114
|
+
self._send(web.render_login(face.rand))
|
|
115
|
+
return
|
|
116
|
+
if path not in face._known_paths:
|
|
117
|
+
self._send("<html><body>Not Found</body></html>", 404)
|
|
118
|
+
return
|
|
119
|
+
with face._lock:
|
|
120
|
+
page = web.render_page(face.state, face.spec, path, {})
|
|
121
|
+
self._send(page)
|
|
122
|
+
|
|
123
|
+
def do_POST(self) -> None:
|
|
124
|
+
path = self.path.split("?", 1)[0]
|
|
125
|
+
form = self._body()
|
|
126
|
+
if path == face.spec.login_path:
|
|
127
|
+
ok = face._login_response(form) == "OK"
|
|
128
|
+
self._send("OK" if ok else "Login failed", cookie=ok)
|
|
129
|
+
return
|
|
130
|
+
if path not in face._known_paths:
|
|
131
|
+
self._send("<html><body>Not Found</body></html>", 404)
|
|
132
|
+
return
|
|
133
|
+
with face._lock:
|
|
134
|
+
web.apply_form(face.state, face.spec, path, form)
|
|
135
|
+
page = web.render_page(face.state, face.spec, path, form)
|
|
136
|
+
self._send(page)
|
|
137
|
+
|
|
138
|
+
server = ThreadingHTTPServer((self.host, 0), Handler)
|
|
139
|
+
self._server = server
|
|
140
|
+
self._thread = threading.Thread(
|
|
141
|
+
target=server.serve_forever, name="virtual-http-face", daemon=True
|
|
142
|
+
)
|
|
143
|
+
self._thread.start()
|
|
144
|
+
return int(server.server_address[1])
|
|
145
|
+
|
|
146
|
+
def _login_response(self, form: dict[str, str]) -> str:
|
|
147
|
+
field = self.spec.password_field
|
|
148
|
+
supplied = form.get(field, "")
|
|
149
|
+
if self.spec.scheme is LoginScheme.CHEETAH_FORM:
|
|
150
|
+
ok = supplied == self.password
|
|
151
|
+
else:
|
|
152
|
+
ok = supplied == merge_hash_md5(self.password, self.rand)
|
|
153
|
+
return "OK" if ok else "Login failed"
|
|
154
|
+
|
|
155
|
+
def stop(self) -> None:
|
|
156
|
+
"""Stop the serve thread and close the listening socket deterministically."""
|
|
157
|
+
if self._server is not None:
|
|
158
|
+
self._server.shutdown()
|
|
159
|
+
if self._thread is not None:
|
|
160
|
+
self._thread.join(timeout=5)
|
|
161
|
+
self._thread = None
|
|
162
|
+
if self._server is not None:
|
|
163
|
+
self._server.server_close()
|
|
164
|
+
self._server = None
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# src/netgear_switch/virtual/faces/mibview.py
|
|
2
|
+
"""Pure OID responder over VirtualSwitchState.oid_map().
|
|
3
|
+
|
|
4
|
+
No pysnmp, no network: a sorted (oid_tuple, snmp_type, value) list answering
|
|
5
|
+
exact-match GET and lexicographic GETNEXT with bisect. Task 15 wires this into
|
|
6
|
+
the real pysnmp command responder.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import bisect
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from ..state import VirtualSwitchState
|
|
15
|
+
|
|
16
|
+
_Entry = tuple[tuple[int, ...], str, str] # (oid_tuple, snmp_type, value)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _oid_to_tuple(oid: str) -> tuple[int, ...]:
|
|
20
|
+
return tuple(int(part) for part in oid.lstrip(".").split("."))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class StateMibView:
|
|
24
|
+
"""Sorted view of a switch's OID map supporting GET and GETNEXT."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, state: VirtualSwitchState) -> None:
|
|
27
|
+
self._state = state
|
|
28
|
+
self._load()
|
|
29
|
+
|
|
30
|
+
def _load(self) -> None:
|
|
31
|
+
entries: list[_Entry] = [
|
|
32
|
+
(_oid_to_tuple(oid), snmp_type, value)
|
|
33
|
+
for oid, (snmp_type, value) in self._state.oid_map().items()
|
|
34
|
+
]
|
|
35
|
+
entries.sort(key=lambda e: e[0])
|
|
36
|
+
self._entries = entries
|
|
37
|
+
self._oids = [e[0] for e in entries] # parallel key list for bisect
|
|
38
|
+
|
|
39
|
+
def get(self, oid: tuple[int, ...]) -> _Entry | None:
|
|
40
|
+
i = bisect.bisect_left(self._oids, oid)
|
|
41
|
+
if i < len(self._oids) and self._oids[i] == oid:
|
|
42
|
+
return self._entries[i]
|
|
43
|
+
return None # caller maps None -> NoSuchInstance
|
|
44
|
+
|
|
45
|
+
def get_next(self, oid: tuple[int, ...]) -> _Entry | None:
|
|
46
|
+
# bisect_right -> index of the first OID strictly greater than `oid`.
|
|
47
|
+
i = bisect.bisect_right(self._oids, oid)
|
|
48
|
+
if i < len(self._oids):
|
|
49
|
+
return self._entries[i]
|
|
50
|
+
return None # caller maps None -> endOfMibView
|
|
51
|
+
|
|
52
|
+
def rebuild(self) -> None:
|
|
53
|
+
"""Recompute the sorted view from current state (call after a write)."""
|
|
54
|
+
self._load()
|
|
55
|
+
|
|
56
|
+
def apply_write(self, oid: str, value: int | bytes | str) -> None:
|
|
57
|
+
"""Mutate the underlying state then rebuild so reads reflect the write."""
|
|
58
|
+
self._state.apply_write(oid, value)
|
|
59
|
+
self.rebuild()
|
|
60
|
+
|
|
61
|
+
def apply_write_uncommitted(self, oid: str, value: int | bytes | str) -> None:
|
|
62
|
+
"""Mutate the underlying state WITHOUT rebuilding the sorted view.
|
|
63
|
+
|
|
64
|
+
For an atomic multi-varbind SET (``faces/snmp.py``'s
|
|
65
|
+
``write_variables``): the (relatively expensive) ``rebuild()`` is
|
|
66
|
+
deferred until the whole PDU has committed successfully, once, rather
|
|
67
|
+
than once per varbind. Callers MUST call ``rebuild()`` themselves
|
|
68
|
+
once every varbind in the PDU has applied without error.
|
|
69
|
+
"""
|
|
70
|
+
self._state.apply_write(oid, value)
|
|
71
|
+
|
|
72
|
+
def snapshot_state(self) -> VirtualSwitchState:
|
|
73
|
+
"""Snapshot the underlying state, for atomic multi-varbind SET rollback.
|
|
74
|
+
|
|
75
|
+
See ``VirtualSwitchState.snapshot``/``restore_state``.
|
|
76
|
+
"""
|
|
77
|
+
return self._state.snapshot()
|
|
78
|
+
|
|
79
|
+
def restore_state(self, snapshot: VirtualSwitchState) -> None:
|
|
80
|
+
"""Restore the underlying state in place from a prior
|
|
81
|
+
``snapshot_state()`` result, discarding any writes applied since.
|
|
82
|
+
|
|
83
|
+
The sorted view itself needs no rebuild after a restore: if the
|
|
84
|
+
caller took the snapshot before making any changes and only ever
|
|
85
|
+
reaches this on a failed atomic SET, the state (and thus the view)
|
|
86
|
+
is back to exactly what it was before that SET began.
|
|
87
|
+
"""
|
|
88
|
+
self._state.restore(snapshot)
|
|
89
|
+
|
|
90
|
+
def is_writable_oid(self, oid: str) -> bool:
|
|
91
|
+
"""Passthrough to ``VirtualSwitchState.is_writable_oid`` (see there)."""
|
|
92
|
+
return self._state.is_writable_oid(oid)
|