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.
Files changed (66) hide show
  1. netgear_switch/__init__.py +132 -0
  2. netgear_switch/_dispatch.py +178 -0
  3. netgear_switch/_version.py +24 -0
  4. netgear_switch/aio_api.py +529 -0
  5. netgear_switch/cli/__init__.py +1 -0
  6. netgear_switch/cli/capture.py +131 -0
  7. netgear_switch/cli/context.py +39 -0
  8. netgear_switch/cli/format.py +201 -0
  9. netgear_switch/cli/main.py +484 -0
  10. netgear_switch/cli/resolve.py +108 -0
  11. netgear_switch/cli/safety.py +71 -0
  12. netgear_switch/config.py +184 -0
  13. netgear_switch/errors.py +52 -0
  14. netgear_switch/http_read.py +174 -0
  15. netgear_switch/http_write.py +420 -0
  16. netgear_switch/models.py +156 -0
  17. netgear_switch/nsdp_read.py +221 -0
  18. netgear_switch/nsdp_write.py +315 -0
  19. netgear_switch/protocols/__init__.py +1 -0
  20. netgear_switch/protocols/http/__init__.py +1 -0
  21. netgear_switch/protocols/http/crypt.py +29 -0
  22. netgear_switch/protocols/http/endpoints.py +165 -0
  23. netgear_switch/protocols/http/forms.py +77 -0
  24. netgear_switch/protocols/http/parse.py +238 -0
  25. netgear_switch/protocols/http/session.py +29 -0
  26. netgear_switch/protocols/nsdp/__init__.py +7 -0
  27. netgear_switch/protocols/nsdp/auth.py +33 -0
  28. netgear_switch/protocols/nsdp/client.py +67 -0
  29. netgear_switch/protocols/nsdp/parsers.py +209 -0
  30. netgear_switch/protocols/nsdp/protocol.py +201 -0
  31. netgear_switch/protocols/nsdp/types.py +137 -0
  32. netgear_switch/protocols/nsdp/write.py +98 -0
  33. netgear_switch/protocols/snmp/__init__.py +1 -0
  34. netgear_switch/protocols/snmp/client.py +88 -0
  35. netgear_switch/protocols/snmp/oids.py +125 -0
  36. netgear_switch/protocols/snmp/parse.py +777 -0
  37. netgear_switch/protocols/snmp/write.py +112 -0
  38. netgear_switch/py.typed +0 -0
  39. netgear_switch/registry.py +227 -0
  40. netgear_switch/snmp_read.py +226 -0
  41. netgear_switch/snmp_write.py +625 -0
  42. netgear_switch/sync_api.py +557 -0
  43. netgear_switch/transport/__init__.py +1 -0
  44. netgear_switch/transport/aio/__init__.py +1 -0
  45. netgear_switch/transport/aio/nsdp_udp.py +152 -0
  46. netgear_switch/transport/aio/snmp_pysnmp.py +247 -0
  47. netgear_switch/transport/http/__init__.py +1 -0
  48. netgear_switch/transport/http/client.py +217 -0
  49. netgear_switch/transport/sync/__init__.py +1 -0
  50. netgear_switch/transport/sync/nsdp_udp.py +109 -0
  51. netgear_switch/transport/sync/snmp_netsnmp_cli.py +257 -0
  52. netgear_switch/virtual/__init__.py +8 -0
  53. netgear_switch/virtual/faces/__init__.py +2 -0
  54. netgear_switch/virtual/faces/http.py +164 -0
  55. netgear_switch/virtual/faces/mibview.py +92 -0
  56. netgear_switch/virtual/faces/nsdp.py +124 -0
  57. netgear_switch/virtual/faces/snmp.py +412 -0
  58. netgear_switch/virtual/seed.py +220 -0
  59. netgear_switch/virtual/server.py +106 -0
  60. netgear_switch/virtual/state.py +615 -0
  61. netgear_switch/virtual/web.py +210 -0
  62. python_netgear_switch_library-0.0.post154.dist-info/METADATA +85 -0
  63. python_netgear_switch_library-0.0.post154.dist-info/RECORD +66 -0
  64. python_netgear_switch_library-0.0.post154.dist-info/WHEEL +4 -0
  65. python_netgear_switch_library-0.0.post154.dist-info/entry_points.txt +2 -0
  66. 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,2 @@
1
+ """Protocol faces onto a VirtualSwitchState (e.g. an SNMP agent, Task 15)."""
2
+ 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)