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,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()
|