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,615 @@
|
|
|
1
|
+
"""The one authoritative in-memory virtual-switch device state.
|
|
2
|
+
|
|
3
|
+
``VirtualSwitchState`` holds everything a simulated switch "knows" about
|
|
4
|
+
itself — port link/admin/speed, counters, VLANs, PoE, sensors, the MAC/FDB
|
|
5
|
+
table, LLDP neighbours and the management IP — as small mutable ``*Sim``
|
|
6
|
+
dataclasses. ``oid_map()`` projects that state onto the flat numeric
|
|
7
|
+
OID -> (snmp_type, value) view a protocol face (Task 15) serves and the
|
|
8
|
+
Task 5-9 parsers consume. This module is pure data + projection: no network.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import copy
|
|
13
|
+
import dataclasses
|
|
14
|
+
import struct
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from typing import TYPE_CHECKING
|
|
17
|
+
|
|
18
|
+
from ..registry import get_model
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from ..protocols.nsdp.protocol import Tag, TLVEntry
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def encode_port_bitmap(ports: set[int], width_bytes: int = 8) -> str:
|
|
25
|
+
"""Inverse of ``parse.decode_port_bitmap``: a port set -> a latin-1 bitmap.
|
|
26
|
+
|
|
27
|
+
Delegates to the canonical bytes encoder in
|
|
28
|
+
``protocols/snmp/write.encode_port_bitmap`` (single source of truth for the
|
|
29
|
+
MSB-first bit-packing) and decodes to the latin-1 ``str`` this module's
|
|
30
|
+
callers expect.
|
|
31
|
+
"""
|
|
32
|
+
from ..protocols.snmp.write import encode_port_bitmap as _encode_bytes
|
|
33
|
+
|
|
34
|
+
return _encode_bytes(ports, width_bytes).decode("latin-1")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _mbps_to_speed_byte(mbps: int) -> int:
|
|
38
|
+
"""Map a negotiated Mbps rate to its NSDP LinkSpeed wire byte."""
|
|
39
|
+
return {10: 0x02, 100: 0x04, 1000: 0x05, 10000: 0xFF}.get(mbps, 0x00)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class PortSim:
|
|
44
|
+
"""One switch port's link/admin/speed/name plus optional HC counters.
|
|
45
|
+
|
|
46
|
+
Counters are ``int | None``: ``None`` means "this port does not expose
|
|
47
|
+
this counter" and must round-trip to an *absent* row in ``oid_map()`` (no
|
|
48
|
+
fabricated zero), so ``parse_port_stats`` yields ``None`` there too.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
name: str
|
|
52
|
+
admin: bool
|
|
53
|
+
link: bool
|
|
54
|
+
speed: int
|
|
55
|
+
rx_octets: int | None = None
|
|
56
|
+
tx_octets: int | None = None
|
|
57
|
+
rx_ucast: int | None = None
|
|
58
|
+
tx_ucast: int | None = None
|
|
59
|
+
rx_errors: int | None = None
|
|
60
|
+
tx_errors: int | None = None
|
|
61
|
+
# ifAlias (operator-set port description). None = this port's ifAlias
|
|
62
|
+
# column instance is entirely absent (never configured), mirroring real
|
|
63
|
+
# hardware where an unset alias may not answer at all -- not a fabricated
|
|
64
|
+
# "".
|
|
65
|
+
description: str | None = None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class VlanSim:
|
|
70
|
+
"""One dot1q VLAN: display name plus egress-member and untagged port sets."""
|
|
71
|
+
|
|
72
|
+
name: str
|
|
73
|
+
member: set[int] = field(default_factory=set)
|
|
74
|
+
untagged: set[int] = field(default_factory=set)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
class PoeSim:
|
|
79
|
+
"""One PoE port: RFC3621 admin/detect state plus vendor delivered power."""
|
|
80
|
+
|
|
81
|
+
admin: bool
|
|
82
|
+
detect: int
|
|
83
|
+
power_mw: int = 0
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass
|
|
87
|
+
class SensorSim:
|
|
88
|
+
"""One box sensor reading (fan RPM / PSU watts / temperature).
|
|
89
|
+
|
|
90
|
+
``raw`` is the literal wire text: either a decimal integer string or
|
|
91
|
+
Netgear's ``"Not Supported"`` placeholder for an unpopulated slot.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
kind: str # "fan" | "power" | "temperature"
|
|
95
|
+
instance: str
|
|
96
|
+
raw: str
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass
|
|
100
|
+
class MacSim:
|
|
101
|
+
"""One learned MAC/FDB entry: VLAN, 6-byte MAC, bridge-port index."""
|
|
102
|
+
|
|
103
|
+
vlan: int
|
|
104
|
+
mac_bytes: tuple[int, int, int, int, int, int]
|
|
105
|
+
bridge_port: int
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass
|
|
109
|
+
class LldpSim:
|
|
110
|
+
"""One lldpRemTable neighbour row group."""
|
|
111
|
+
|
|
112
|
+
time_mark: int
|
|
113
|
+
local_port: int
|
|
114
|
+
rem_idx: int
|
|
115
|
+
chassis: str
|
|
116
|
+
port_id: str
|
|
117
|
+
port_desc: str
|
|
118
|
+
sys_name: str
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass
|
|
122
|
+
class MgmtSim:
|
|
123
|
+
"""The switch's own management-IP configuration."""
|
|
124
|
+
|
|
125
|
+
address: str
|
|
126
|
+
netmask: str
|
|
127
|
+
gateway: str
|
|
128
|
+
mode: str # "static" | "dhcp"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass
|
|
132
|
+
class VirtualSwitchState:
|
|
133
|
+
"""The one authoritative virtual-switch device state.
|
|
134
|
+
|
|
135
|
+
A mutable holder (later slices mutate it to simulate writes); pure data
|
|
136
|
+
plus the ``oid_map()`` SNMP projection, no network here.
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
model_key: str
|
|
140
|
+
ports: dict[int, PortSim] = field(default_factory=dict)
|
|
141
|
+
vlans: dict[int, VlanSim] = field(default_factory=dict)
|
|
142
|
+
pvids: dict[int, int] = field(default_factory=dict)
|
|
143
|
+
poe: dict[int, PoeSim] = field(default_factory=dict)
|
|
144
|
+
sensors: list[SensorSim] = field(default_factory=list)
|
|
145
|
+
macs: list[MacSim] = field(default_factory=list)
|
|
146
|
+
bridge_ports: dict[int, int] = field(default_factory=dict)
|
|
147
|
+
lldp: list[LldpSim] = field(default_factory=list)
|
|
148
|
+
mgmt: MgmtSim = field(
|
|
149
|
+
default_factory=lambda: MgmtSim(
|
|
150
|
+
address="0.0.0.0", netmask="0.0.0.0", gateway="0.0.0.0", mode="dhcp"
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
model_name: str = ""
|
|
154
|
+
serial: str = ""
|
|
155
|
+
firmware: str = ""
|
|
156
|
+
hostname: str = ""
|
|
157
|
+
nsdp_password: str = "password"
|
|
158
|
+
# QoS engine mode (NSDP tag 0x3400): None = unseeded (tag omitted from
|
|
159
|
+
# nsdp_tlvs(), exactly like a real switch that doesn't answer it).
|
|
160
|
+
nsdp_qos_engine: int | None = None
|
|
161
|
+
# Port mirroring (NSDP tag 0x5C00): None destination = unseeded/disabled.
|
|
162
|
+
nsdp_port_mirroring_dest: int | None = None
|
|
163
|
+
nsdp_port_mirroring_sources: frozenset[int] = field(default_factory=frozenset)
|
|
164
|
+
# IGMP snooping (NSDP tag 0x6800): None = unseeded.
|
|
165
|
+
nsdp_igmp_snooping_enabled: bool | None = None
|
|
166
|
+
nsdp_igmp_snooping_vlan: int | None = None
|
|
167
|
+
# Broadcast storm filtering (NSDP tag 0x5400): None = unseeded.
|
|
168
|
+
nsdp_broadcast_filtering: bool | None = None
|
|
169
|
+
# Loop detection (NSDP tag 0x9000): None = unseeded.
|
|
170
|
+
nsdp_loop_detection: bool | None = None
|
|
171
|
+
# Fixed seed MAC for device identity: the NSDP identity TLV (Tag.MAC /
|
|
172
|
+
# server_mac) AND the SNMP dot1dBaseBridgeAddress scalar (see oid_map())
|
|
173
|
+
# both project this same value -- on real hardware they're the same
|
|
174
|
+
# physical base MAC.
|
|
175
|
+
nsdp_mac: bytes = b"\x28\xc6\x8e\x00\x00\x01"
|
|
176
|
+
# MIB-II sysDescr (Task 2 model detection). Empty means unseeded: oid_map()
|
|
177
|
+
# falls back to a generic-but-real-model-name text derived from the
|
|
178
|
+
# registry's own display_name, so sysDescr-based detection works out of
|
|
179
|
+
# the box for every SNMP-capable registered model, not just the ones with
|
|
180
|
+
# a hand-authored seed_*() (see seed_gsm7252ps for the hand-seeded case).
|
|
181
|
+
sys_descr: str = ""
|
|
182
|
+
# UNVERIFIED sysObjectID test fixture -- see oid_map(). There is no known
|
|
183
|
+
# real sysObjectID -> model table (no MIBs/captures/prior-art exist for
|
|
184
|
+
# one); this value is NEVER a claim about real hardware, purely a
|
|
185
|
+
# plausible-looking virtual/test placeholder under the model's own
|
|
186
|
+
# 1.3.6.1.4.1.4526 vendor subtree, so sysObjectID round-trips end-to-end.
|
|
187
|
+
# Empty means unseeded: oid_map() derives one from the model's vendor base.
|
|
188
|
+
sys_object_id: str = ""
|
|
189
|
+
|
|
190
|
+
def oid_map(self) -> dict[str, tuple[str, str]]:
|
|
191
|
+
"""Project this state onto the full numeric OID -> (type, value) view.
|
|
192
|
+
|
|
193
|
+
Built directly from the exact OID layouts in ``protocols.snmp.oids``
|
|
194
|
+
so a protocol face can serve it and the Task 5-9 parsers reconstruct
|
|
195
|
+
the seeded state from what the face returns.
|
|
196
|
+
"""
|
|
197
|
+
from ..protocols.snmp import oids
|
|
198
|
+
from ..protocols.snmp.write import vlan_bitmap_width
|
|
199
|
+
|
|
200
|
+
model = get_model(self.model_key)
|
|
201
|
+
v = oids.vendor_oids(model)
|
|
202
|
+
vlan_width = vlan_bitmap_width(model)
|
|
203
|
+
m: dict[str, tuple[str, str]] = {}
|
|
204
|
+
|
|
205
|
+
# dot1dBaseBridgeAddress (BRIDGE-MIB scalar): the switch's own base
|
|
206
|
+
# MAC. Reuses `nsdp_mac` -- on a real device the SNMP bridge base
|
|
207
|
+
# address and the NSDP-reported identity MAC are the same physical
|
|
208
|
+
# address, so one seed value serves both protocol faces.
|
|
209
|
+
m[f"{oids.DOT1D_BASE_BRIDGE_ADDRESS}.0"] = (
|
|
210
|
+
"OCTETSTR", self.nsdp_mac.decode("latin-1"))
|
|
211
|
+
|
|
212
|
+
# MIB-II System group (Task 2 model detection). sysDescr is a REAL,
|
|
213
|
+
# honestly-matchable signal (a real switch's own sysDescr text
|
|
214
|
+
# contains its model name); sysObjectID has no known OID->model
|
|
215
|
+
# table, so the value projected here is an UNVERIFIED virtual/test
|
|
216
|
+
# fixture only -- see the field docstrings above, never trust it as
|
|
217
|
+
# ground truth for a real device.
|
|
218
|
+
m[oids.SYS_DESCR] = (
|
|
219
|
+
"OCTETSTR", self.sys_descr or f"Netgear {model.display_name}"
|
|
220
|
+
)
|
|
221
|
+
m[oids.SYS_OBJECT_ID] = ("OID", self.sys_object_id or f"{v.base}.1")
|
|
222
|
+
|
|
223
|
+
for port, sim in self.ports.items():
|
|
224
|
+
m[f"{oids.IF_ADMIN_STATUS}.{port}"] = ("INTEGER", "1" if sim.admin else "2")
|
|
225
|
+
m[f"{oids.IF_OPER_STATUS}.{port}"] = ("INTEGER", "1" if sim.link else "2")
|
|
226
|
+
m[f"{oids.IF_HIGH_SPEED}.{port}"] = ("Gauge32", str(sim.speed))
|
|
227
|
+
m[f"{oids.IF_NAME}.{port}"] = ("OCTETSTR", sim.name)
|
|
228
|
+
if sim.description is not None:
|
|
229
|
+
m[f"{oids.IF_ALIAS}.{port}"] = ("OCTETSTR", sim.description)
|
|
230
|
+
# Port stats: only emit a counter the port actually exposes
|
|
231
|
+
# (None -> skip, so parse_port_stats yields None there, never a
|
|
232
|
+
# fabricated 0).
|
|
233
|
+
stat_cols: tuple[tuple[str, str, int | None], ...] = (
|
|
234
|
+
(oids.IF_HC_IN_OCTETS, "Counter64", sim.rx_octets),
|
|
235
|
+
(oids.IF_HC_OUT_OCTETS, "Counter64", sim.tx_octets),
|
|
236
|
+
(oids.IF_HC_IN_UCAST, "Counter64", sim.rx_ucast),
|
|
237
|
+
(oids.IF_HC_OUT_UCAST, "Counter64", sim.tx_ucast),
|
|
238
|
+
(oids.IF_IN_ERRORS, "Counter32", sim.rx_errors),
|
|
239
|
+
(oids.IF_OUT_ERRORS, "Counter32", sim.tx_errors),
|
|
240
|
+
)
|
|
241
|
+
for base, typ, val in stat_cols:
|
|
242
|
+
if val is not None:
|
|
243
|
+
m[f"{base}.{port}"] = (typ, str(val))
|
|
244
|
+
|
|
245
|
+
for vid, vsim in self.vlans.items():
|
|
246
|
+
m[f"{oids.DOT1Q_VLAN_STATIC_NAME}.{vid}"] = ("OCTETSTR", vsim.name)
|
|
247
|
+
m[f"{oids.DOT1Q_VLAN_STATIC_EGRESS}.{vid}"] = (
|
|
248
|
+
"OCTETSTR", encode_port_bitmap(vsim.member, width_bytes=vlan_width))
|
|
249
|
+
m[f"{oids.DOT1Q_VLAN_STATIC_UNTAGGED}.{vid}"] = (
|
|
250
|
+
"OCTETSTR", encode_port_bitmap(vsim.untagged, width_bytes=vlan_width))
|
|
251
|
+
|
|
252
|
+
for port, pv in self.pvids.items():
|
|
253
|
+
m[f"{oids.DOT1Q_PVID}.{port}"] = ("Gauge32", str(pv))
|
|
254
|
+
|
|
255
|
+
for port, psim in self.poe.items():
|
|
256
|
+
m[f"{oids.PETH_PSE_PORT_TABLE}.3.1.{port}"] = (
|
|
257
|
+
"INTEGER", "1" if psim.admin else "2")
|
|
258
|
+
m[f"{oids.PETH_PSE_PORT_TABLE}.6.1.{port}"] = (
|
|
259
|
+
"INTEGER", str(psim.detect))
|
|
260
|
+
m[f"{v.poe_power_mw}.1.{port}"] = ("Gauge32", str(psim.power_mw))
|
|
261
|
+
|
|
262
|
+
for ssim in self.sensors:
|
|
263
|
+
base = {
|
|
264
|
+
"fan": v.box_fan,
|
|
265
|
+
"power": v.box_psu_power,
|
|
266
|
+
"temperature": v.box_temp,
|
|
267
|
+
}[ssim.kind]
|
|
268
|
+
m[f"{base}.{ssim.instance}"] = ("OCTETSTR", ssim.raw)
|
|
269
|
+
|
|
270
|
+
# MAC/FDB: dot1qTpFdbPort values keyed by <vlan>.<6 MAC bytes>, plus
|
|
271
|
+
# the dot1dBasePortIfIndex bridge-port -> ifIndex rows the parser
|
|
272
|
+
# joins on.
|
|
273
|
+
for msim in self.macs:
|
|
274
|
+
mac_suffix = ".".join(str(b) for b in msim.mac_bytes)
|
|
275
|
+
m[f"{oids.DOT1Q_TP_FDB_PORT}.{msim.vlan}.{mac_suffix}"] = (
|
|
276
|
+
"INTEGER", str(msim.bridge_port))
|
|
277
|
+
for bridge_port, ifindex in self.bridge_ports.items():
|
|
278
|
+
m[f"{oids.DOT1D_BASE_PORT_IF_INDEX}.{bridge_port}"] = (
|
|
279
|
+
"INTEGER", str(ifindex))
|
|
280
|
+
|
|
281
|
+
# LLDP remote neighbours across lldpRemTable columns 5/7/8/9.
|
|
282
|
+
for nb in self.lldp:
|
|
283
|
+
idx = f"{nb.time_mark}.{nb.local_port}.{nb.rem_idx}"
|
|
284
|
+
m[f"{oids.LLDP_REM_TABLE}.1.5.{idx}"] = ("OCTETSTR", nb.chassis)
|
|
285
|
+
m[f"{oids.LLDP_REM_TABLE}.1.7.{idx}"] = ("OCTETSTR", nb.port_id)
|
|
286
|
+
m[f"{oids.LLDP_REM_TABLE}.1.8.{idx}"] = ("OCTETSTR", nb.port_desc)
|
|
287
|
+
m[f"{oids.LLDP_REM_TABLE}.1.9.{idx}"] = ("OCTETSTR", nb.sys_name)
|
|
288
|
+
|
|
289
|
+
# mgmt-ip: ipAddrTable + ipRouteTable + DHCP mode.
|
|
290
|
+
idx = self.mgmt.address
|
|
291
|
+
m[f"{oids.IP_ADENT_ADDR}.{idx}"] = ("IPADDR", self.mgmt.address)
|
|
292
|
+
m[f"{oids.IP_ADENT_NETMASK}.{idx}"] = ("IPADDR", self.mgmt.netmask)
|
|
293
|
+
m[f"{oids.IP_ROUTE_DEST}.0.0.0.0"] = ("IPADDR", "0.0.0.0")
|
|
294
|
+
m[f"{oids.IP_ROUTE_NEXTHOP}.0.0.0.0"] = ("IPADDR", self.mgmt.gateway)
|
|
295
|
+
# Single named UNVERIFIED DHCP-mode OID (Task 4) — never a bare
|
|
296
|
+
# ".99.1" literal.
|
|
297
|
+
m[f"{v.dhcp_mode_unverified}.0"] = (
|
|
298
|
+
"INTEGER", "2" if self.mgmt.mode == "static" else "1")
|
|
299
|
+
|
|
300
|
+
return m
|
|
301
|
+
|
|
302
|
+
def snapshot(self) -> VirtualSwitchState:
|
|
303
|
+
"""Deep-copy this state, for atomic multi-varbind SET rollback.
|
|
304
|
+
|
|
305
|
+
A single SNMP SET PDU can carry several varbinds (e.g.
|
|
306
|
+
``set_vlan_membership`` writing both the egress and untagged
|
|
307
|
+
bitmaps in one ``set_many`` call) and a real agent guarantees they
|
|
308
|
+
apply all-or-nothing. ``faces/snmp.py``'s ``write_variables``
|
|
309
|
+
snapshots the state before applying a PDU's varbinds and calls
|
|
310
|
+
``restore`` on this snapshot if any of them fails, so a partial
|
|
311
|
+
mutation is never observable. See ``restore``.
|
|
312
|
+
"""
|
|
313
|
+
return copy.deepcopy(self)
|
|
314
|
+
|
|
315
|
+
def restore(self, snapshot: VirtualSwitchState) -> None:
|
|
316
|
+
"""Restore this state in place from a prior ``snapshot()`` result.
|
|
317
|
+
|
|
318
|
+
Copies every dataclass field from ``snapshot`` onto ``self`` rather
|
|
319
|
+
than replacing ``self`` itself, so existing references to this exact
|
|
320
|
+
object (e.g. ``VirtualSwitch.state``, ``StateMibView._state``) keep
|
|
321
|
+
seeing the restored data.
|
|
322
|
+
"""
|
|
323
|
+
for f in dataclasses.fields(self):
|
|
324
|
+
setattr(self, f.name, getattr(snapshot, f.name))
|
|
325
|
+
|
|
326
|
+
def apply_write(self, oid: str, value: int | bytes | str) -> None:
|
|
327
|
+
"""Mutate this state from one SNMP SET varbind, with device coherence.
|
|
328
|
+
|
|
329
|
+
Dispatches on the OID's column prefix. Applies the same coherence a real
|
|
330
|
+
PoE switch shows so ``cycle_poe`` terminates against the mock: admin off
|
|
331
|
+
-> detect=1 (unused) + data-port link down; admin on -> detect=3
|
|
332
|
+
(delivering). Unhandled writable OIDs are a deliberate no-op (the write
|
|
333
|
+
"succeeds" but reads back unchanged), which is exactly what a
|
|
334
|
+
verify-after-write must catch. (The SNMP face layer additionally
|
|
335
|
+
rejects a SET on an OID ``is_writable_oid`` doesn't recognize at all
|
|
336
|
+
with a proper SNMP error, before it ever reaches here — see
|
|
337
|
+
``faces/snmp.py``.)
|
|
338
|
+
"""
|
|
339
|
+
from ..protocols.snmp import oids
|
|
340
|
+
from ..registry import get_model
|
|
341
|
+
|
|
342
|
+
v = oids.vendor_oids(get_model(self.model_key))
|
|
343
|
+
|
|
344
|
+
def _tail(base: str) -> int | None:
|
|
345
|
+
prefix = base + "."
|
|
346
|
+
if oid.startswith(prefix) and oid[len(prefix):].isdigit():
|
|
347
|
+
return int(oid[len(prefix):])
|
|
348
|
+
return None
|
|
349
|
+
|
|
350
|
+
def _as_bytes(val: int | bytes | str) -> bytes:
|
|
351
|
+
if isinstance(val, bytes):
|
|
352
|
+
return val
|
|
353
|
+
if isinstance(val, str):
|
|
354
|
+
return val.encode("latin-1")
|
|
355
|
+
return bytes([val])
|
|
356
|
+
|
|
357
|
+
# ifAdminStatus.<port>
|
|
358
|
+
port = _tail(oids.IF_ADMIN_STATUS)
|
|
359
|
+
if port is not None and port in self.ports:
|
|
360
|
+
self.ports[port].admin = int(value) == 1
|
|
361
|
+
if int(value) != 1:
|
|
362
|
+
self.ports[port].link = False
|
|
363
|
+
return
|
|
364
|
+
|
|
365
|
+
# pethPsePortAdminEnable = <table>.3.1.<port>
|
|
366
|
+
poe_prefix = f"{oids.PETH_PSE_PORT_TABLE}.3.1."
|
|
367
|
+
if oid.startswith(poe_prefix) and oid[len(poe_prefix):].isdigit():
|
|
368
|
+
p = int(oid[len(poe_prefix):])
|
|
369
|
+
if p in self.poe:
|
|
370
|
+
on = int(value) == 1
|
|
371
|
+
self.poe[p].admin = on
|
|
372
|
+
self.poe[p].detect = 3 if on else 1 # delivering / unused
|
|
373
|
+
if not on and p in self.ports:
|
|
374
|
+
self.ports[p].link = False
|
|
375
|
+
return
|
|
376
|
+
|
|
377
|
+
# dot1qPvid.<port>
|
|
378
|
+
port = _tail(oids.DOT1Q_PVID)
|
|
379
|
+
if port is not None:
|
|
380
|
+
self.pvids[port] = int(value)
|
|
381
|
+
return
|
|
382
|
+
|
|
383
|
+
# dot1qVlanStaticEgressPorts.<vid>
|
|
384
|
+
vid = _tail(oids.DOT1Q_VLAN_STATIC_EGRESS)
|
|
385
|
+
if vid is not None and vid in self.vlans:
|
|
386
|
+
from ..protocols.snmp.parse import decode_port_bitmap
|
|
387
|
+
self.vlans[vid].member = set(decode_port_bitmap(_as_bytes(value)))
|
|
388
|
+
return
|
|
389
|
+
|
|
390
|
+
# dot1qVlanStaticUntaggedPorts.<vid>
|
|
391
|
+
vid = _tail(oids.DOT1Q_VLAN_STATIC_UNTAGGED)
|
|
392
|
+
if vid is not None and vid in self.vlans:
|
|
393
|
+
from ..protocols.snmp.parse import decode_port_bitmap
|
|
394
|
+
self.vlans[vid].untagged = set(decode_port_bitmap(_as_bytes(value)))
|
|
395
|
+
return
|
|
396
|
+
|
|
397
|
+
# dot1qVlanStaticRowStatus.<vid> (createAndGo=4 / destroy=6)
|
|
398
|
+
vid = _tail(oids.DOT1Q_VLAN_STATIC_ROW_STATUS)
|
|
399
|
+
if vid is not None:
|
|
400
|
+
if int(value) == oids.ROW_STATUS_DESTROY:
|
|
401
|
+
self.vlans.pop(vid, None)
|
|
402
|
+
elif int(value) == oids.ROW_STATUS_CREATE_AND_GO and vid not in self.vlans:
|
|
403
|
+
self.vlans[vid] = VlanSim(name="")
|
|
404
|
+
return
|
|
405
|
+
|
|
406
|
+
# dot1qVlanStaticName.<vid>
|
|
407
|
+
vid = _tail(oids.DOT1Q_VLAN_STATIC_NAME)
|
|
408
|
+
if vid is not None:
|
|
409
|
+
name = value.decode("latin-1") if isinstance(value, bytes) else str(value)
|
|
410
|
+
if vid in self.vlans:
|
|
411
|
+
self.vlans[vid].name = name
|
|
412
|
+
else:
|
|
413
|
+
self.vlans[vid] = VlanSim(name=name)
|
|
414
|
+
return
|
|
415
|
+
|
|
416
|
+
# UNVERIFIED mgmt-IP write OIDs -> MgmtSim (read projection follows).
|
|
417
|
+
if oid == v.mgmt_write_addr_unverified:
|
|
418
|
+
self.mgmt.address = str(value)
|
|
419
|
+
return
|
|
420
|
+
if oid == v.mgmt_write_netmask_unverified:
|
|
421
|
+
self.mgmt.netmask = str(value)
|
|
422
|
+
return
|
|
423
|
+
if oid == v.mgmt_write_gateway_unverified:
|
|
424
|
+
self.mgmt.gateway = str(value)
|
|
425
|
+
return
|
|
426
|
+
|
|
427
|
+
# UNVERIFIED dhcp-mode write OID (same scalar the read projection
|
|
428
|
+
# advertises, mirroring the mgmt-write precedent above): 2=static,
|
|
429
|
+
# anything else=dhcp, matching oid_map()'s own encoding exactly.
|
|
430
|
+
if oid == f"{v.dhcp_mode_unverified}.0":
|
|
431
|
+
self.mgmt.mode = "static" if int(value) == 2 else "dhcp"
|
|
432
|
+
return
|
|
433
|
+
|
|
434
|
+
# Unhandled writable OID: deliberate no-op (verify-after-write catches it).
|
|
435
|
+
|
|
436
|
+
def nsdp_tlvs(self, tags: set[Tag]) -> list[TLVEntry]:
|
|
437
|
+
"""Project this state onto NSDP read TLVs for the requested tags.
|
|
438
|
+
|
|
439
|
+
MODEL / MAC / PORT_COUNT identity is always included (a real Plus switch
|
|
440
|
+
echoes it, and ``parse_device`` needs the model + a port count to size
|
|
441
|
+
VLAN bitmaps). Only tags this mock knows are emitted; unknown requested
|
|
442
|
+
tags are silently skipped, exactly as real hardware does.
|
|
443
|
+
"""
|
|
444
|
+
import socket
|
|
445
|
+
|
|
446
|
+
from ..protocols.nsdp.protocol import Tag, TLVEntry
|
|
447
|
+
|
|
448
|
+
model = get_model(self.model_key)
|
|
449
|
+
port_count = model.port_count
|
|
450
|
+
width = (port_count + 7) // 8
|
|
451
|
+
model_bytes = (self.model_name or model.display_name).encode("ascii")
|
|
452
|
+
out: list[TLVEntry] = [
|
|
453
|
+
TLVEntry(Tag.MODEL, model_bytes),
|
|
454
|
+
TLVEntry(Tag.MAC, self.nsdp_mac),
|
|
455
|
+
TLVEntry(Tag.PORT_COUNT, bytes([port_count])),
|
|
456
|
+
]
|
|
457
|
+
if Tag.SERIAL_NUMBER in tags and self.serial:
|
|
458
|
+
serial_bytes = b"\x01" + self.serial.encode("ascii")
|
|
459
|
+
out.append(TLVEntry(Tag.SERIAL_NUMBER, serial_bytes))
|
|
460
|
+
if Tag.HOSTNAME in tags and self.hostname:
|
|
461
|
+
out.append(TLVEntry(Tag.HOSTNAME, self.hostname.encode("ascii")))
|
|
462
|
+
if Tag.FIRMWARE_VER_1 in tags and self.firmware:
|
|
463
|
+
out.append(TLVEntry(Tag.FIRMWARE_VER_1, self.firmware.encode("ascii")))
|
|
464
|
+
if Tag.PORT_STATUS in tags:
|
|
465
|
+
for port, sim in sorted(self.ports.items()):
|
|
466
|
+
speed_byte = _mbps_to_speed_byte(sim.speed) if sim.link else 0x00
|
|
467
|
+
out.append(TLVEntry(Tag.PORT_STATUS, bytes([port, speed_byte, 0x01])))
|
|
468
|
+
if Tag.PORT_STATISTICS in tags:
|
|
469
|
+
for port, sim in sorted(self.ports.items()):
|
|
470
|
+
if sim.rx_octets is None:
|
|
471
|
+
continue
|
|
472
|
+
out.append(
|
|
473
|
+
TLVEntry(
|
|
474
|
+
Tag.PORT_STATISTICS,
|
|
475
|
+
bytes([port])
|
|
476
|
+
+ struct.pack(">Q", sim.rx_octets or 0)
|
|
477
|
+
+ struct.pack(">Q", sim.tx_octets or 0)
|
|
478
|
+
+ struct.pack(">Q", sim.rx_errors or 0)
|
|
479
|
+
+ b"\x00" * 24,
|
|
480
|
+
)
|
|
481
|
+
)
|
|
482
|
+
if Tag.VLAN_MEMBERS in tags:
|
|
483
|
+
from ..protocols.nsdp.parsers import ports_to_bitmap
|
|
484
|
+
for vid, vsim in sorted(self.vlans.items()):
|
|
485
|
+
tagged = vsim.member - vsim.untagged
|
|
486
|
+
out.append(
|
|
487
|
+
TLVEntry(
|
|
488
|
+
Tag.VLAN_MEMBERS,
|
|
489
|
+
struct.pack(">H", vid)
|
|
490
|
+
+ ports_to_bitmap(vsim.member, width)
|
|
491
|
+
+ ports_to_bitmap(tagged, width),
|
|
492
|
+
)
|
|
493
|
+
)
|
|
494
|
+
if Tag.PORT_PVID in tags:
|
|
495
|
+
for port, pv in sorted(self.pvids.items()):
|
|
496
|
+
pvid_bytes = bytes([port]) + struct.pack(">H", pv)
|
|
497
|
+
out.append(TLVEntry(Tag.PORT_PVID, pvid_bytes))
|
|
498
|
+
if Tag.IP_ADDRESS in tags:
|
|
499
|
+
out.append(TLVEntry(Tag.IP_ADDRESS, socket.inet_aton(self.mgmt.address)))
|
|
500
|
+
if Tag.NETMASK in tags:
|
|
501
|
+
out.append(TLVEntry(Tag.NETMASK, socket.inet_aton(self.mgmt.netmask)))
|
|
502
|
+
if Tag.GATEWAY in tags:
|
|
503
|
+
out.append(TLVEntry(Tag.GATEWAY, socket.inet_aton(self.mgmt.gateway)))
|
|
504
|
+
if Tag.DHCP_MODE in tags:
|
|
505
|
+
dhcp_byte = b"\x00" if self.mgmt.mode == "static" else b"\x01"
|
|
506
|
+
out.append(TLVEntry(Tag.DHCP_MODE, dhcp_byte))
|
|
507
|
+
if Tag.QOS_ENGINE in tags and self.nsdp_qos_engine is not None:
|
|
508
|
+
out.append(TLVEntry(Tag.QOS_ENGINE, bytes([self.nsdp_qos_engine])))
|
|
509
|
+
if Tag.PORT_MIRRORING in tags and self.nsdp_port_mirroring_dest is not None:
|
|
510
|
+
from ..protocols.nsdp.parsers import ports_to_bitmap
|
|
511
|
+
out.append(
|
|
512
|
+
TLVEntry(
|
|
513
|
+
Tag.PORT_MIRRORING,
|
|
514
|
+
bytes([self.nsdp_port_mirroring_dest])
|
|
515
|
+
+ ports_to_bitmap(self.nsdp_port_mirroring_sources, 3),
|
|
516
|
+
)
|
|
517
|
+
)
|
|
518
|
+
if Tag.IGMP_SNOOPING in tags and self.nsdp_igmp_snooping_enabled is not None:
|
|
519
|
+
vlan_byte = self.nsdp_igmp_snooping_vlan or 0
|
|
520
|
+
out.append(
|
|
521
|
+
TLVEntry(
|
|
522
|
+
Tag.IGMP_SNOOPING,
|
|
523
|
+
bytes([0x00, 1 if self.nsdp_igmp_snooping_enabled else 0,
|
|
524
|
+
0x00, vlan_byte]),
|
|
525
|
+
)
|
|
526
|
+
)
|
|
527
|
+
if (Tag.BROADCAST_FILTERING in tags
|
|
528
|
+
and self.nsdp_broadcast_filtering is not None):
|
|
529
|
+
out.append(
|
|
530
|
+
TLVEntry(
|
|
531
|
+
Tag.BROADCAST_FILTERING,
|
|
532
|
+
bytes([1 if self.nsdp_broadcast_filtering else 0]),
|
|
533
|
+
)
|
|
534
|
+
)
|
|
535
|
+
if Tag.LOOP_DETECTION in tags and self.nsdp_loop_detection is not None:
|
|
536
|
+
out.append(
|
|
537
|
+
TLVEntry(
|
|
538
|
+
Tag.LOOP_DETECTION,
|
|
539
|
+
bytes([1 if self.nsdp_loop_detection else 0]),
|
|
540
|
+
)
|
|
541
|
+
)
|
|
542
|
+
return out
|
|
543
|
+
|
|
544
|
+
def apply_nsdp_write(self, tag: Tag | int, value: bytes) -> None:
|
|
545
|
+
"""Mutate this state from one NSDP write TLV (verify-after-write reads it
|
|
546
|
+
back). Unknown/read-only tags are a deliberate no-op."""
|
|
547
|
+
import socket
|
|
548
|
+
|
|
549
|
+
from ..protocols.nsdp.parsers import parse_vlan_members
|
|
550
|
+
from ..protocols.nsdp.protocol import Tag
|
|
551
|
+
|
|
552
|
+
model = get_model(self.model_key)
|
|
553
|
+
if tag == Tag.PORT_PVID:
|
|
554
|
+
self.pvids[value[0]] = struct.unpack_from(">H", value, 1)[0]
|
|
555
|
+
elif tag == Tag.VLAN_MEMBERS:
|
|
556
|
+
m = parse_vlan_members(value, model.port_count)
|
|
557
|
+
existing = self.vlans.get(m.vlan_id)
|
|
558
|
+
name = existing.name if existing is not None else ""
|
|
559
|
+
self.vlans[m.vlan_id] = VlanSim(
|
|
560
|
+
name=name,
|
|
561
|
+
member=set(m.member_ports),
|
|
562
|
+
untagged=set(m.untagged_ports),
|
|
563
|
+
)
|
|
564
|
+
elif tag == Tag.IP_ADDRESS:
|
|
565
|
+
self.mgmt.address = socket.inet_ntoa(value)
|
|
566
|
+
elif tag == Tag.NETMASK:
|
|
567
|
+
self.mgmt.netmask = socket.inet_ntoa(value)
|
|
568
|
+
elif tag == Tag.GATEWAY:
|
|
569
|
+
self.mgmt.gateway = socket.inet_ntoa(value)
|
|
570
|
+
elif tag == Tag.DHCP_MODE:
|
|
571
|
+
self.mgmt.mode = "dhcp" if value[:1] == b"\x01" else "static"
|
|
572
|
+
# REBOOT / FACTORY_RESET / unknown: deliberate no-op.
|
|
573
|
+
|
|
574
|
+
def is_writable_oid(self, oid: str) -> bool:
|
|
575
|
+
"""True if ``oid`` is one this mock recognizes as SNMP-writable.
|
|
576
|
+
|
|
577
|
+
Mirrors ``apply_write``'s dispatch prefixes on purpose (single set of
|
|
578
|
+
column constants from ``protocols.snmp.oids``, kept in sync
|
|
579
|
+
deliberately) so the SNMP face (``faces/snmp.py``) can reject a SET on
|
|
580
|
+
a genuinely unknown/read-only OID with a proper SNMP error
|
|
581
|
+
(notWritable) instead of the always-succeeding no-op ``apply_write``
|
|
582
|
+
itself deliberately allows for a recognized-but-absent instance (e.g.
|
|
583
|
+
creating a not-yet-existing VLAN row).
|
|
584
|
+
"""
|
|
585
|
+
from ..protocols.snmp import oids
|
|
586
|
+
from ..registry import get_model
|
|
587
|
+
|
|
588
|
+
v = oids.vendor_oids(get_model(self.model_key))
|
|
589
|
+
|
|
590
|
+
def _is_col(base: str) -> bool:
|
|
591
|
+
prefix = base + "."
|
|
592
|
+
return oid.startswith(prefix) and oid[len(prefix):].isdigit()
|
|
593
|
+
|
|
594
|
+
if _is_col(oids.IF_ADMIN_STATUS):
|
|
595
|
+
return True
|
|
596
|
+
poe_prefix = f"{oids.PETH_PSE_PORT_TABLE}.3.1."
|
|
597
|
+
if oid.startswith(poe_prefix) and oid[len(poe_prefix):].isdigit():
|
|
598
|
+
return True
|
|
599
|
+
if _is_col(oids.DOT1Q_PVID):
|
|
600
|
+
return True
|
|
601
|
+
if _is_col(oids.DOT1Q_VLAN_STATIC_EGRESS):
|
|
602
|
+
return True
|
|
603
|
+
if _is_col(oids.DOT1Q_VLAN_STATIC_UNTAGGED):
|
|
604
|
+
return True
|
|
605
|
+
if _is_col(oids.DOT1Q_VLAN_STATIC_ROW_STATUS):
|
|
606
|
+
return True
|
|
607
|
+
if _is_col(oids.DOT1Q_VLAN_STATIC_NAME):
|
|
608
|
+
return True
|
|
609
|
+
if oid in (
|
|
610
|
+
v.mgmt_write_addr_unverified,
|
|
611
|
+
v.mgmt_write_netmask_unverified,
|
|
612
|
+
v.mgmt_write_gateway_unverified,
|
|
613
|
+
):
|
|
614
|
+
return True
|
|
615
|
+
return oid == f"{v.dhcp_mode_unverified}.0"
|