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,124 @@
1
+ """A real UDP NSDP responder serving a VirtualSwitchState.
2
+
3
+ Mirrors the pysnmp face pattern (Task 15's ``VirtualSnmpFace``) but for the far
4
+ simpler NSDP wire protocol: a single background thread with one UDP socket bound
5
+ to an ephemeral port on loopback (so no root, no privileged 63321/63322 bind,
6
+ no SO_BINDTODEVICE). It answers READ_REQUEST from ``state.nsdp_tlvs`` and applies
7
+ WRITE_REQUEST after validating the v1 ``PASSWORD`` TLV (a mismatch returns result
8
+ 0x0700, exactly as real hardware does — the transport turns that into an
9
+ ``NsdpError``). ``stop()`` closes the socket deterministically so no
10
+ ResourceWarning is emitted under ``-W error::ResourceWarning``.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import contextlib
15
+ import socket
16
+ import threading
17
+ from typing import TYPE_CHECKING
18
+
19
+ from ...protocols.nsdp.auth import encode_password_v1
20
+ from ...protocols.nsdp.protocol import NSDPPacket, Op, Tag
21
+ from ...protocols.nsdp.write import RESULT_BAD_PASSWORD, RESULT_SUCCESS
22
+
23
+ if TYPE_CHECKING:
24
+ from ..state import VirtualSwitchState
25
+
26
+
27
+ class VirtualNsdpFace:
28
+ """A UDP NSDP command responder serving a ``VirtualSwitchState``."""
29
+
30
+ def __init__(self, state: VirtualSwitchState, *, host: str = "127.0.0.1") -> None:
31
+ self._state = state
32
+ self._host = host
33
+ self._port = 0
34
+ self._sock: socket.socket | None = None
35
+ self._thread: threading.Thread | None = None
36
+ self._stop = threading.Event()
37
+
38
+ def start(self) -> int:
39
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
40
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
41
+ sock.bind((self._host, 0))
42
+ sock.settimeout(0.2) # so the serve loop can observe _stop promptly
43
+ self._port = sock.getsockname()[1]
44
+ self._sock = sock
45
+ self._stop.clear()
46
+ self._thread = threading.Thread(
47
+ target=self._serve, name="virtual-nsdp-face", daemon=True
48
+ )
49
+ self._thread.start()
50
+ return self._port
51
+
52
+ def _serve(self) -> None:
53
+ assert self._sock is not None
54
+ while not self._stop.is_set():
55
+ try:
56
+ data, addr = self._sock.recvfrom(4096)
57
+ except TimeoutError:
58
+ continue
59
+ except OSError:
60
+ break
61
+ try:
62
+ response = self._handle(data)
63
+ except ValueError:
64
+ continue # malformed request datagram: ignore, as hardware does
65
+ if response is not None:
66
+ with contextlib.suppress(OSError):
67
+ self._sock.sendto(response.encode(), addr)
68
+
69
+ def _handle(self, data: bytes) -> NSDPPacket | None:
70
+ req = NSDPPacket.decode(data)
71
+ if req.op == Op.READ_REQUEST:
72
+ return self._read_response(req)
73
+ if req.op == Op.WRITE_REQUEST:
74
+ return self._write_response(req)
75
+ return None
76
+
77
+ def _read_response(self, req: NSDPPacket) -> NSDPPacket:
78
+ # Only catalogued ``Tag`` values are meaningful read requests; a raw
79
+ # uncatalogued int tag (``TLVEntry.decode``'s fallback) can't match
80
+ # anything ``nsdp_tlvs`` knows to serve, so it's dropped here rather
81
+ # than widening ``nsdp_tlvs``'s ``set[Tag]`` contract to ``int`` too.
82
+ tags = {t.tag for t in req.tlvs if isinstance(t.tag, Tag)}
83
+ resp = NSDPPacket(
84
+ op=Op.READ_RESPONSE,
85
+ client_mac=req.client_mac,
86
+ server_mac=self._state.nsdp_mac,
87
+ sequence=req.sequence,
88
+ )
89
+ resp.tlvs = self._state.nsdp_tlvs(tags)
90
+ return resp
91
+
92
+ def _write_response(self, req: NSDPPacket) -> NSDPPacket:
93
+ expected = encode_password_v1(self._state.nsdp_password)
94
+ # Plain ``==`` compare is intentionally NOT constant-time: this is a
95
+ # local, loopback-only test mock, not a security boundary.
96
+ password_ok = any(
97
+ t.tag == Tag.PASSWORD and t.value == expected for t in req.tlvs
98
+ )
99
+ resp = NSDPPacket(
100
+ op=Op.WRITE_RESPONSE,
101
+ client_mac=req.client_mac,
102
+ server_mac=self._state.nsdp_mac,
103
+ sequence=req.sequence,
104
+ )
105
+ if not password_ok:
106
+ resp.result = RESULT_BAD_PASSWORD
107
+ return resp
108
+ for tlv in req.tlvs:
109
+ if tlv.tag != Tag.PASSWORD:
110
+ self._state.apply_nsdp_write(tlv.tag, tlv.value)
111
+ resp.result = RESULT_SUCCESS
112
+ return resp
113
+
114
+ def stop(self) -> None:
115
+ """Stop the serve thread and close the socket deterministically."""
116
+ self._stop.set()
117
+ if self._thread is not None:
118
+ self._thread.join(timeout=5)
119
+ self._thread = None
120
+ if self._sock is not None:
121
+ with contextlib.suppress(OSError):
122
+ self._sock.close()
123
+ self._sock = None
124
+ self._port = 0
@@ -0,0 +1,412 @@
1
+ # src/netgear_switch/virtual/faces/snmp.py
2
+ """A real pysnmp v2c command-responder agent serving a StateMibView.
3
+
4
+ This wires the pure ``StateMibView`` (Task 14) into an actual pysnmp v7 agent
5
+ bound to an ephemeral UDP port on 127.0.0.1, so both transport clients
6
+ (``NetsnmpCliClient`` and ``PysnmpClient``) can be exercised end-to-end
7
+ against a mock switch.
8
+
9
+ pysnmp is imported lazily (only when ``VirtualSnmpFace.start()`` runs, inside
10
+ the background thread), so this module — and the rest of the ``virtual``
11
+ package — stays importable without the ``[testing]``/``[async]`` extra
12
+ installed. pysnmp ships no type stubs; every reference is resolved through
13
+ ``importlib.import_module`` (returning ``Any``), the same single-seam
14
+ pattern used by ``transport/aio/snmp_pysnmp.py``, so mypy --strict needs no
15
+ ``ignore_missing_imports`` override for pysnmp at all.
16
+
17
+ **Adaptations from the Task 15 brief's sample code** (the brief explicitly
18
+ warned its snippets might be stale — they were):
19
+
20
+ * The brief's ``_StateInstrum`` sketch used ``read_vars``/``read_next_vars``.
21
+ The *actual* installed pysnmp v7 MIB-instrumentation-controller callback
22
+ names are ``read_variables``/``read_next_variables`` (confirmed by reading
23
+ ``pysnmp.smi.instrum.AbstractMibInstrumController`` and how
24
+ ``pysnmp.entity.rfc3413.cmdrsp.{Get,Next,Bulk}CommandResponder`` invoke
25
+ them: ``self.snmpContext.get_mib_instrum(contextName).read_variables``.
26
+ ``readVars``/``readNextVars`` exist only as *deprecated old-camelCase*
27
+ aliases for those, never as ``read_vars``/``read_next_vars``).
28
+ * Rather than raising ``NoSuchInstanceError``/``EndOfMibViewError`` (which
29
+ ``GetCommandResponder``/``NextCommandResponder`` would turn into a
30
+ whole-PDU ``genErr`` — not spec-conformant SNMPv2c behaviour, and not what
31
+ ``PysnmpClient``/net-snmp expect), this controller embeds the real
32
+ ``pysnmp.proto.rfc1905`` exception *values* (``noSuchInstance`` /
33
+ ``endOfMibView``) directly into the response var-bind, exactly as a real
34
+ SNMPv2c agent does and exactly what both transport clients already treat
35
+ as an absent-OID / walk-terminator marker.
36
+ * The custom controller is a plain class (no pysnmp base class to inherit
37
+ from without a *static* pysnmp import, which would defeat the lazy-import
38
+ seam) — it only needs to duck-type ``read_variables``/``read_next_variables``,
39
+ which is all ``cmdrsp`` ever calls on it.
40
+ * The engine/transport/VACM setup follows ``pysnmp.entity.config``'s
41
+ ``add_transport``/``add_v1_system``/``add_vacm_user`` (the real, current
42
+ function names — no ``addTransport``/``addV1System`` camelCase, those are
43
+ deprecated aliases too).
44
+
45
+ **Task 17 (write path) additions, verified the same way against the
46
+ installed pysnmp v7 rather than trusted from a brief:**
47
+
48
+ * ``SetCommandResponder.handle_management_operation`` (read via
49
+ ``inspect.getsource``) calls
50
+ ``self.snmpContext.get_mib_instrum(contextName).write_variables`` — so the
51
+ controller callback is named ``write_variables`` (matching the
52
+ ``read_variables``/``read_next_variables`` naming above), not
53
+ ``write_vars``.
54
+ * That same source shows ``CommandResponderBase.process_pdu`` catches any
55
+ ``pysnmp.smi.error.SmiError`` raised out of ``handle_management_operation``
56
+ and maps its *exact class* through ``SMI_ERROR_MAP`` to an SNMP
57
+ error-status (``WrongValueError`` -> ``wrongValue``, ``NotWritableError``
58
+ -> ``notWritable``) — confirming both errors below travel cleanly to the
59
+ client instead of the whole-PDU ``genErr``/timeout a bare exception would
60
+ cause.
61
+ * That handler then does ``errorIndex = errorIndication["idx"] + 1``. Passing
62
+ ``idx=None`` (as sketched in the brief) would make this
63
+ ``None + 1`` -> an unhandled ``TypeError`` inside pysnmp's own error path —
64
+ a worse failure than the one being guarded against. ``write_variables``
65
+ below passes the real 0-based position of the failing var-bind instead.
66
+ * ``add_vacm_user`` already existed for reads; granting SET access is just
67
+ passing ``writeSubTree=(1, 3, 6, 1)`` alongside the existing
68
+ ``readSubTree`` — same function, no new API.
69
+ """
70
+ from __future__ import annotations
71
+
72
+ import asyncio
73
+ import contextlib
74
+ import importlib
75
+ import socket
76
+ import threading
77
+ from typing import TYPE_CHECKING, Any
78
+
79
+ if TYPE_CHECKING:
80
+ from .mibview import StateMibView
81
+
82
+ # SNMPv2c security model ID (pysnmp.proto.secmod.rfc2576.SnmpV2cSecurityModel
83
+ # .SECURITY_MODEL_ID); SNMPv1's is 1. Not exported anywhere more convenient.
84
+ _SNMP_V2C_SECURITY_MODEL = 2
85
+
86
+
87
+ def _pysnmp_engine() -> Any:
88
+ return importlib.import_module("pysnmp.entity.engine")
89
+
90
+
91
+ def _pysnmp_config() -> Any:
92
+ return importlib.import_module("pysnmp.entity.config")
93
+
94
+
95
+ def _pysnmp_cmdrsp() -> Any:
96
+ return importlib.import_module("pysnmp.entity.rfc3413.cmdrsp")
97
+
98
+
99
+ def _pysnmp_context() -> Any:
100
+ return importlib.import_module("pysnmp.entity.rfc3413.context")
101
+
102
+
103
+ def _pysnmp_udp() -> Any:
104
+ return importlib.import_module("pysnmp.carrier.asyncio.dgram.udp")
105
+
106
+
107
+ def _pysnmp_rfc1902() -> Any:
108
+ return importlib.import_module("pysnmp.proto.rfc1902")
109
+
110
+
111
+ def _pysnmp_rfc1905() -> Any:
112
+ return importlib.import_module("pysnmp.proto.rfc1905")
113
+
114
+
115
+ def _pysnmp_smi_error() -> Any:
116
+ return importlib.import_module("pysnmp.smi.error")
117
+
118
+
119
+ def _to_smi_value(snmp_type: str, value: str) -> Any:
120
+ """Convert one ``StateMibView`` ``(snmp_type, value)`` pair to the
121
+ matching pysnmp SMI value object, so it goes on the wire with the right
122
+ BER type.
123
+
124
+ ``OCTETSTR`` values are always encoded latin-1 -> bytes: ``oid_map()``
125
+ (Task 14) stores every octet-string value, printable or not (VLAN
126
+ bitmaps, LLDP chassis IDs, port names, "Not Supported" sensor text), as a
127
+ ``str`` produced via ``chr(byte)``/plain ASCII, so a latin-1 encode is
128
+ the exact inverse in every case and round-trips the seeded bytes exactly.
129
+ """
130
+ rfc1902 = _pysnmp_rfc1902()
131
+ if snmp_type == "INTEGER":
132
+ return rfc1902.Integer32(int(value))
133
+ if snmp_type == "Gauge32":
134
+ return rfc1902.Gauge32(int(value))
135
+ if snmp_type == "Counter32":
136
+ return rfc1902.Counter32(int(value))
137
+ if snmp_type == "Counter64":
138
+ return rfc1902.Counter64(int(value))
139
+ if snmp_type == "IPADDR":
140
+ return rfc1902.IpAddress(value)
141
+ if snmp_type == "OCTETSTR":
142
+ return rfc1902.OctetString(value.encode("latin-1"))
143
+ if snmp_type == "OID":
144
+ return rfc1902.ObjectIdentifier(value)
145
+ raise ValueError(f"unsupported snmp_type token: {snmp_type!r}")
146
+
147
+
148
+ def _from_smi_value(value: Any) -> int | bytes | str:
149
+ """Convert an incoming pysnmp SET value to a plain Python value for the mock."""
150
+ cls = value.__class__.__name__
151
+ if cls in ("Integer", "Integer32", "Gauge32", "Unsigned32"):
152
+ return int(value)
153
+ if cls == "OctetString":
154
+ return bytes(value.asOctets())
155
+ if cls == "IpAddress":
156
+ return str(value.prettyPrint())
157
+ return str(value.prettyPrint())
158
+
159
+
160
+ class _StateInstrum:
161
+ """Adapts ``StateMibView.get``/``get_next`` to pysnmp's MIB-instrumentation
162
+ controller callbacks.
163
+
164
+ Owns no ordering/lookup logic of its own (that all lives in
165
+ ``StateMibView``) — it only translates var-bind OIDs to/from tuples and
166
+ ``(snmp_type, value)`` pairs to pysnmp SMI values. Duck-typed rather than
167
+ subclassing a pysnmp base class, since pysnmp is only ever imported
168
+ lazily here.
169
+ """
170
+
171
+ def __init__(self, view: StateMibView) -> None:
172
+ self._view = view
173
+ rfc1905 = _pysnmp_rfc1905()
174
+ self._no_such_instance = rfc1905.noSuchInstance
175
+ self._end_of_mib_view = rfc1905.endOfMibView
176
+ smi_error = _pysnmp_smi_error()
177
+ self._write_error = smi_error.WrongValueError
178
+ self._not_writable_error = smi_error.NotWritableError
179
+
180
+ def read_variables(
181
+ self, *var_binds: tuple[Any, Any], **_context: Any
182
+ ) -> list[tuple[Any, Any]]:
183
+ """Answer a GET: exact-match lookup per requested OID."""
184
+ out: list[tuple[Any, Any]] = []
185
+ for name, _val in var_binds:
186
+ entry = self._view.get(tuple(name))
187
+ if entry is None:
188
+ out.append((name, self._no_such_instance))
189
+ else:
190
+ _oid, snmp_type, value = entry
191
+ out.append((name, _to_smi_value(snmp_type, value)))
192
+ return out
193
+
194
+ def read_next_variables(
195
+ self, *var_binds: tuple[Any, Any], **_context: Any
196
+ ) -> list[tuple[Any, Any]]:
197
+ """Answer one GETNEXT/GETBULK step: the next OID after each request."""
198
+ out: list[tuple[Any, Any]] = []
199
+ for name, _val in var_binds:
200
+ entry = self._view.get_next(tuple(name))
201
+ if entry is None:
202
+ out.append((name, self._end_of_mib_view))
203
+ else:
204
+ next_oid, snmp_type, value = entry
205
+ out.append((next_oid, _to_smi_value(snmp_type, value)))
206
+ return out
207
+
208
+ def write_variables(
209
+ self, *var_binds: tuple[Any, Any], **_context: Any
210
+ ) -> list[tuple[Any, Any]]:
211
+ """Answer a SET: mutate state atomically, echo the written varbinds.
212
+
213
+ The whole PDU is ALL-OR-NOTHING, matching a real SNMP agent and the
214
+ ``set_many`` "one PDU (atomic)" contract (``protocols/snmp/client.py``)
215
+ that e.g. ``set_vlan_membership`` relies on when it writes the egress
216
+ AND untagged bitmaps as a single SET: if any varbind in the PDU is
217
+ rejected, NONE of the PDU's varbinds may have mutated state, even the
218
+ ones already processed earlier in this same call.
219
+
220
+ Implemented via snapshot-then-restore rather than validate-then-commit:
221
+ some failures (e.g. a malformed integer value only discovered when
222
+ ``apply_write`` itself calls ``int(value)``) only surface mid-apply,
223
+ so a clean up-front validation pass would have to duplicate
224
+ ``apply_write``'s own parsing. Instead, the state is snapshotted
225
+ before the loop; every varbind applies via
226
+ ``apply_write_uncommitted`` (which mutates but does NOT rebuild the
227
+ view — rebuilding once, only after the whole PDU has committed, also
228
+ avoids the per-varbind rebuild this used to do); if any varbind
229
+ fails, ``restore_state`` rolls the state back to that snapshot before
230
+ the (unchanged) SMI error propagates, so no partial mutation is ever
231
+ observable.
232
+
233
+ An OID ``StateMibView.is_writable_oid`` doesn't recognize at all is
234
+ rejected with a pysnmp ``NotWritableError`` (a clean SNMP
235
+ ``notWritable`` error-status) rather than the silent, always-succeeds
236
+ no-op ``apply_write`` deliberately allows for a recognized-but-absent
237
+ instance (e.g. creating a not-yet-existing VLAN row) — that no-op is
238
+ a mock-fidelity choice for a *known* writable column, not licence to
239
+ accept an arbitrary/bogus OID.
240
+
241
+ A failure in ``apply_write`` itself (malformed value, unexpected
242
+ type, ...) is converted to a pysnmp ``WrongValueError`` so the
243
+ responder returns a clean SNMP error-status; it is never allowed to
244
+ escape into the dispatcher (which the client would observe as a
245
+ timeout = flaky test).
246
+
247
+ ``idx`` is passed as the 0-based position of the failing varbind
248
+ within this call, not ``None``: pysnmp's command-responder computes
249
+ ``errorIndication["idx"] + 1`` when building the SNMP error response,
250
+ so a ``None`` idx would raise an unhandled ``TypeError`` inside
251
+ pysnmp's own error path instead of a clean SNMP error (confirmed by
252
+ reading ``CommandResponderBase.process_pdu`` on the installed pysnmp
253
+ v7 — a deviation from the brief's sample, which used ``idx=None``).
254
+ """
255
+ snapshot = self._view.snapshot_state()
256
+ out: list[tuple[Any, Any]] = []
257
+ try:
258
+ for idx, (name, val) in enumerate(var_binds):
259
+ oid = ".".join(str(x) for x in tuple(name))
260
+ if not self._view.is_writable_oid(oid):
261
+ raise self._not_writable_error(name=name, idx=idx)
262
+ try:
263
+ self._view.apply_write_uncommitted(oid, _from_smi_value(val))
264
+ except Exception as exc: # map to a clean SMI error, never leak
265
+ raise self._write_error(name=name, idx=idx) from exc
266
+ out.append((name, val))
267
+ except Exception:
268
+ # Any varbind in this PDU failed: undo every mutation this call
269
+ # made so far (there may be none, one, or several), so the whole
270
+ # SET is atomic. The original SMI error (NotWritableError /
271
+ # WrongValueError, already carrying the right idx) propagates
272
+ # unchanged.
273
+ self._view.restore_state(snapshot)
274
+ raise
275
+ self._view.rebuild()
276
+ return out
277
+
278
+
279
+ class VirtualSnmpFace:
280
+ """A pysnmp v2c command-responder agent serving a ``StateMibView``.
281
+
282
+ Runs the pysnmp asyncio dispatcher on a dedicated background thread with
283
+ its own event loop, bound to an ephemeral UDP port on ``host``.
284
+ """
285
+
286
+ def __init__(
287
+ self, view: StateMibView, *, community: str = "public", host: str = "127.0.0.1"
288
+ ) -> None:
289
+ self._view = view
290
+ self._community = community
291
+ self._host = host
292
+ self._port = 0
293
+ self._engine: Any = None
294
+ self._loop: asyncio.AbstractEventLoop | None = None
295
+ self._thread: threading.Thread | None = None
296
+ self._ready = threading.Event()
297
+ self._start_error: Exception | None = None
298
+ # The raw UDP socket the agent binds, captured here (not fished out of
299
+ # pysnmp) since it is this exact object we hand to
300
+ # ``UdpTransport.open_server_mode(sock=sock)`` in ``_run`` — asyncio's
301
+ # ``create_datagram_endpoint(sock=...)`` path never dup()s a passed-in
302
+ # socket, it wraps this literal object. See ``stop()`` for why closing
303
+ # it ourselves, deterministically, is necessary.
304
+ self._sock: socket.socket | None = None
305
+
306
+ def start(self) -> int:
307
+ """Bind the UDP socket, start the agent thread, and return the port."""
308
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
309
+ sock.bind((self._host, 0))
310
+ self._port = sock.getsockname()[1]
311
+ self._sock = sock
312
+
313
+ self._ready.clear()
314
+ self._start_error = None
315
+ self._thread = threading.Thread(
316
+ target=self._run, args=(sock,), name="virtual-snmp-face", daemon=True
317
+ )
318
+ self._thread.start()
319
+ self._ready.wait()
320
+ if self._start_error is not None:
321
+ raise self._start_error
322
+ return self._port
323
+
324
+ def stop(self) -> None:
325
+ """Close the dispatcher, join the background thread, and close the
326
+ agent's UDP socket deterministically.
327
+
328
+ pysnmp's ``AsyncioDispatcher.close_dispatcher`` closes the asyncio
329
+ transport by scheduling its real close (``loop.call_soon(...)``) for
330
+ the *next* loop iteration, then immediately calls ``loop.stop()`` in
331
+ that same callback — which breaks ``run_forever()`` before that next
332
+ iteration ever runs. ``_run`` already compensates for this (it pumps
333
+ the loop once more after ``run_forever()`` returns, so pysnmp's own
334
+ deferred close normally does run before the loop is closed). Closing
335
+ ``self._sock`` here too, after the thread has fully stopped, is a
336
+ deliberate belt-and-braces backstop: it guarantees the fd is closed
337
+ deterministically even if that compensation ever fails to run (e.g.
338
+ a future pysnmp change altering the callback ordering), rather than
339
+ depending on GC to eventually close it and emit a ResourceWarning.
340
+ """
341
+ if self._loop is not None and self._engine is not None:
342
+ self._loop.call_soon_threadsafe(self._engine.close_dispatcher)
343
+ if self._thread is not None:
344
+ self._thread.join(timeout=5)
345
+ self._engine = None
346
+ self._loop = None
347
+ self._thread = None
348
+ if self._sock is not None:
349
+ # Already closed (e.g. by pysnmp's own deferred cleanup) is fine.
350
+ with contextlib.suppress(OSError):
351
+ self._sock.close()
352
+ self._sock = None
353
+
354
+ def _run(self, sock: socket.socket) -> None:
355
+ loop = asyncio.new_event_loop()
356
+ asyncio.set_event_loop(loop)
357
+
358
+ try:
359
+ engine_mod = _pysnmp_engine()
360
+ config = _pysnmp_config()
361
+ cmdrsp = _pysnmp_cmdrsp()
362
+ context = _pysnmp_context()
363
+ udp = _pysnmp_udp()
364
+
365
+ engine = engine_mod.SnmpEngine()
366
+ transport = udp.UdpTransport(loop=loop).open_server_mode(sock=sock)
367
+ config.add_transport(engine, udp.DOMAIN_NAME, transport)
368
+
369
+ config.add_v1_system(engine, "netgear-virtual", self._community)
370
+ config.add_vacm_user(
371
+ engine,
372
+ _SNMP_V2C_SECURITY_MODEL,
373
+ "netgear-virtual",
374
+ "noAuthNoPriv",
375
+ readSubTree=(1, 3, 6, 1),
376
+ writeSubTree=(1, 3, 6, 1),
377
+ )
378
+
379
+ snmp_context = context.SnmpContext(engine)
380
+ snmp_context.context_names[b""] = _StateInstrum(self._view)
381
+
382
+ cmdrsp.GetCommandResponder(engine, snmp_context)
383
+ cmdrsp.NextCommandResponder(engine, snmp_context)
384
+ cmdrsp.BulkCommandResponder(engine, snmp_context)
385
+ cmdrsp.SetCommandResponder(engine, snmp_context)
386
+ except Exception as exc: # surfaced to start() via _start_error, not swallowed
387
+ self._start_error = exc
388
+ self._ready.set()
389
+ loop.close()
390
+ return
391
+
392
+ self._engine = engine
393
+ self._loop = loop
394
+ self._ready.set()
395
+ try:
396
+ engine.open_dispatcher()
397
+ finally:
398
+ # ``stop()`` schedules ``engine.close_dispatcher()`` then calls
399
+ # ``loop.stop()`` in that very callback — before the asyncio
400
+ # transport's own deferred close (itself scheduled via
401
+ # ``loop.call_soon`` a moment earlier, by
402
+ # ``close_dispatcher``'s ``transport.close_transport()``) gets a
403
+ # turn to run. Left alone, that means the real UDP socket is
404
+ # never closed by asyncio's own machinery, only by GC later —
405
+ # a reproducible "unclosed transport"/"unclosed socket"
406
+ # ResourceWarning on every stop. Running the loop for one more
407
+ # complete iteration here (still on this same thread, before
408
+ # ``loop.close()``) lets that already-queued deferred callback
409
+ # execute, so the transport (and the raw socket it wraps —
410
+ # ``self._sock`` in ``start()``) close deterministically instead.
411
+ loop.run_until_complete(asyncio.sleep(0))
412
+ loop.close()