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,221 @@
|
|
|
1
|
+
"""Model-driven NSDP read operations over a sync or async client.
|
|
2
|
+
|
|
3
|
+
Parallel to ``snmp_read.py``. Maps NSDP TLVs onto the SAME public ``models``
|
|
4
|
+
types. NSDP genuinely exposes only port link/speed, byte/CRC statistics, VLAN
|
|
5
|
+
membership, PVID and management IP on these Plus switches; MAC/FDB, LLDP,
|
|
6
|
+
sensors and PoE are not in the protocol, so those ops raise
|
|
7
|
+
``UnsupportedCapabilityError`` rather than fabricating empty results.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from .errors import UnsupportedCapabilityError
|
|
14
|
+
from .models import IpMode, MgmtIpConfig, PortStats, PortStatus, VLANInfo
|
|
15
|
+
from .protocols.nsdp.parsers import parse_device
|
|
16
|
+
from .protocols.nsdp.protocol import Tag
|
|
17
|
+
from .protocols.nsdp.types import LinkSpeed
|
|
18
|
+
from .registry import Backend
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from .models import LLDPNeighbor, MacEntry, PoEStatus, Sensor
|
|
22
|
+
from .protocols.nsdp.client import AsyncNsdpClient, NsdpClient
|
|
23
|
+
from .protocols.nsdp.types import NsdpDevice
|
|
24
|
+
from .registry import SwitchModel
|
|
25
|
+
|
|
26
|
+
_NO_MACS = "NSDP exposes no MAC/FDB table (Plus switches have no remote FDB)"
|
|
27
|
+
_NO_LLDP = "NSDP exposes no LLDP neighbours on these Plus switches"
|
|
28
|
+
_NO_SENSORS = "NSDP exposes no environmental sensors on these Plus switches"
|
|
29
|
+
_NO_POE = "NSDP exposes no PoE status; use the HTTP backend (Slice 6) for PoE"
|
|
30
|
+
|
|
31
|
+
# Every read-tag ``parse_device`` knows how to decode, requested together so
|
|
32
|
+
# ``get_device()``/``nsdp_device()`` returns the COMPLETE NsdpDevice in one
|
|
33
|
+
# round trip -- identity, mgmt IP, per-port status/stats, VLANs/PVIDs, and the
|
|
34
|
+
# QoS/mirroring/IGMP/broadcast-filtering/loop-detection tags (see parsers.py).
|
|
35
|
+
_FULL_DEVICE_TAGS = [
|
|
36
|
+
Tag.MODEL,
|
|
37
|
+
Tag.MAC,
|
|
38
|
+
Tag.HOSTNAME,
|
|
39
|
+
Tag.IP_ADDRESS,
|
|
40
|
+
Tag.NETMASK,
|
|
41
|
+
Tag.GATEWAY,
|
|
42
|
+
Tag.FIRMWARE_VER_1,
|
|
43
|
+
Tag.DHCP_MODE,
|
|
44
|
+
Tag.PORT_COUNT,
|
|
45
|
+
Tag.SERIAL_NUMBER,
|
|
46
|
+
Tag.VLAN_ENGINE,
|
|
47
|
+
Tag.PORT_STATUS,
|
|
48
|
+
Tag.PORT_STATISTICS,
|
|
49
|
+
Tag.VLAN_MEMBERS,
|
|
50
|
+
Tag.PORT_PVID,
|
|
51
|
+
Tag.QOS_ENGINE,
|
|
52
|
+
Tag.PORT_MIRRORING,
|
|
53
|
+
Tag.IGMP_SNOOPING,
|
|
54
|
+
Tag.BROADCAST_FILTERING,
|
|
55
|
+
Tag.LOOP_DETECTION,
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _require_nsdp(model: SwitchModel) -> None:
|
|
60
|
+
if Backend.NSDP not in model.backends:
|
|
61
|
+
raise UnsupportedCapabilityError(f"model {model.key!r} has no NSDP backend")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _ports(dev: NsdpDevice) -> list[PortStatus]:
|
|
65
|
+
return [
|
|
66
|
+
PortStatus(
|
|
67
|
+
port=s.port_id,
|
|
68
|
+
name=None, # NSDP PORT_STATUS carries no port name
|
|
69
|
+
# NSDP PORT_STATUS reports link speed only; it cannot distinguish an
|
|
70
|
+
# admin-disabled port from a link-down one, so admin_enabled is
|
|
71
|
+
# reported True (the honest "not administratively removed" default).
|
|
72
|
+
admin_enabled=True,
|
|
73
|
+
link_up=s.speed is not LinkSpeed.DOWN,
|
|
74
|
+
speed_mbps=s.speed.speed_mbps or None,
|
|
75
|
+
)
|
|
76
|
+
for s in dev.port_status
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _stats(dev: NsdpDevice) -> list[PortStats]:
|
|
81
|
+
return [
|
|
82
|
+
PortStats(
|
|
83
|
+
port=s.port_id,
|
|
84
|
+
rx_bytes=s.bytes_received,
|
|
85
|
+
tx_bytes=s.bytes_sent,
|
|
86
|
+
rx_packets=None, # NSDP PORT_STATISTICS has no packet counters
|
|
87
|
+
tx_packets=None,
|
|
88
|
+
rx_errors=s.crc_errors,
|
|
89
|
+
tx_errors=None,
|
|
90
|
+
)
|
|
91
|
+
for s in dev.port_statistics
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _vlans(dev: NsdpDevice) -> list[VLANInfo]:
|
|
96
|
+
return [
|
|
97
|
+
VLANInfo(
|
|
98
|
+
vlan_id=m.vlan_id,
|
|
99
|
+
name=None, # NSDP VLAN_MEMBERS carries no VLAN name
|
|
100
|
+
member_ports=m.member_ports,
|
|
101
|
+
tagged_ports=m.tagged_ports,
|
|
102
|
+
untagged_ports=m.untagged_ports,
|
|
103
|
+
)
|
|
104
|
+
for m in dev.vlan_members
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _mgmt(dev: NsdpDevice) -> MgmtIpConfig:
|
|
109
|
+
if dev.dhcp_enabled is None:
|
|
110
|
+
mode = IpMode.UNKNOWN
|
|
111
|
+
else:
|
|
112
|
+
mode = IpMode.DHCP if dev.dhcp_enabled else IpMode.STATIC
|
|
113
|
+
return MgmtIpConfig(
|
|
114
|
+
mode=mode, address=dev.ip, netmask=dev.netmask, gateway=dev.gateway,
|
|
115
|
+
# NSDP always echoes the device MAC (Tag.MAC, with a server_mac
|
|
116
|
+
# fallback -- see parse_device), so this is honestly always
|
|
117
|
+
# populated, never a guess. Uppercased to match the SNMP-backend
|
|
118
|
+
# formatting (parse.parse_base_mac / _format_mac_bytes) so the public
|
|
119
|
+
# field has one consistent case across backends.
|
|
120
|
+
base_mac=dev.mac.upper(),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class NsdpReader:
|
|
125
|
+
"""Synchronous NSDP read facade over one switch."""
|
|
126
|
+
|
|
127
|
+
def __init__(self, client: NsdpClient, model: SwitchModel) -> None:
|
|
128
|
+
_require_nsdp(model)
|
|
129
|
+
self.client = client
|
|
130
|
+
self.model = model
|
|
131
|
+
|
|
132
|
+
def _device(self, tags: list[Tag]) -> NsdpDevice:
|
|
133
|
+
return parse_device(self.client.read(tags))
|
|
134
|
+
|
|
135
|
+
def get_ports(self) -> list[PortStatus]:
|
|
136
|
+
return _ports(self._device([Tag.PORT_COUNT, Tag.PORT_STATUS]))
|
|
137
|
+
|
|
138
|
+
def get_stats(self) -> list[PortStats]:
|
|
139
|
+
return _stats(self._device([Tag.PORT_STATISTICS]))
|
|
140
|
+
|
|
141
|
+
def get_vlans(self) -> list[VLANInfo]:
|
|
142
|
+
return _vlans(self._device([Tag.PORT_COUNT, Tag.VLAN_MEMBERS]))
|
|
143
|
+
|
|
144
|
+
def get_pvids(self) -> list[tuple[int, int]]:
|
|
145
|
+
dev = self._device([Tag.PORT_PVID])
|
|
146
|
+
return [(p.port_id, p.vlan_id) for p in dev.port_pvids]
|
|
147
|
+
|
|
148
|
+
def get_mgmt_ip(self) -> MgmtIpConfig:
|
|
149
|
+
return _mgmt(
|
|
150
|
+
self._device([Tag.IP_ADDRESS, Tag.NETMASK, Tag.GATEWAY, Tag.DHCP_MODE])
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
def get_device(self) -> NsdpDevice:
|
|
154
|
+
"""Return the COMPLETE raw ``NsdpDevice`` for this switch: every tag
|
|
155
|
+
``parse_device`` knows how to decode, in one round trip. Unlike the
|
|
156
|
+
other ``get_*`` ops above, this returns the NSDP-native shape
|
|
157
|
+
(including the raw port-status speed byte) rather than mapping onto
|
|
158
|
+
the shared ``models`` types -- callers that need the full protocol
|
|
159
|
+
surface (e.g. gdoc2netcfg's DiscoveryDB) use this instead of the
|
|
160
|
+
per-field ops."""
|
|
161
|
+
return self._device(_FULL_DEVICE_TAGS)
|
|
162
|
+
|
|
163
|
+
def get_macs(self) -> list[MacEntry]:
|
|
164
|
+
raise UnsupportedCapabilityError(_NO_MACS)
|
|
165
|
+
|
|
166
|
+
def get_lldp(self) -> list[LLDPNeighbor]:
|
|
167
|
+
raise UnsupportedCapabilityError(_NO_LLDP)
|
|
168
|
+
|
|
169
|
+
def get_sensors(self) -> list[Sensor]:
|
|
170
|
+
raise UnsupportedCapabilityError(_NO_SENSORS)
|
|
171
|
+
|
|
172
|
+
def get_poe(self) -> list[PoEStatus]:
|
|
173
|
+
raise UnsupportedCapabilityError(_NO_POE)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class AsyncNsdpReader:
|
|
177
|
+
"""Asynchronous NSDP read facade (mirror of NsdpReader)."""
|
|
178
|
+
|
|
179
|
+
def __init__(self, client: AsyncNsdpClient, model: SwitchModel) -> None:
|
|
180
|
+
_require_nsdp(model)
|
|
181
|
+
self.client = client
|
|
182
|
+
self.model = model
|
|
183
|
+
|
|
184
|
+
async def _device(self, tags: list[Tag]) -> NsdpDevice:
|
|
185
|
+
return parse_device(await self.client.read(tags))
|
|
186
|
+
|
|
187
|
+
async def get_ports(self) -> list[PortStatus]:
|
|
188
|
+
return _ports(await self._device([Tag.PORT_COUNT, Tag.PORT_STATUS]))
|
|
189
|
+
|
|
190
|
+
async def get_stats(self) -> list[PortStats]:
|
|
191
|
+
return _stats(await self._device([Tag.PORT_STATISTICS]))
|
|
192
|
+
|
|
193
|
+
async def get_vlans(self) -> list[VLANInfo]:
|
|
194
|
+
return _vlans(await self._device([Tag.PORT_COUNT, Tag.VLAN_MEMBERS]))
|
|
195
|
+
|
|
196
|
+
async def get_pvids(self) -> list[tuple[int, int]]:
|
|
197
|
+
dev = await self._device([Tag.PORT_PVID])
|
|
198
|
+
return [(p.port_id, p.vlan_id) for p in dev.port_pvids]
|
|
199
|
+
|
|
200
|
+
async def get_mgmt_ip(self) -> MgmtIpConfig:
|
|
201
|
+
return _mgmt(
|
|
202
|
+
await self._device(
|
|
203
|
+
[Tag.IP_ADDRESS, Tag.NETMASK, Tag.GATEWAY, Tag.DHCP_MODE]
|
|
204
|
+
)
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
async def get_device(self) -> NsdpDevice:
|
|
208
|
+
"""Async twin of ``NsdpReader.get_device`` -- see there."""
|
|
209
|
+
return await self._device(_FULL_DEVICE_TAGS)
|
|
210
|
+
|
|
211
|
+
async def get_macs(self) -> list[MacEntry]:
|
|
212
|
+
raise UnsupportedCapabilityError(_NO_MACS)
|
|
213
|
+
|
|
214
|
+
async def get_lldp(self) -> list[LLDPNeighbor]:
|
|
215
|
+
raise UnsupportedCapabilityError(_NO_LLDP)
|
|
216
|
+
|
|
217
|
+
async def get_sensors(self) -> list[Sensor]:
|
|
218
|
+
raise UnsupportedCapabilityError(_NO_SENSORS)
|
|
219
|
+
|
|
220
|
+
async def get_poe(self) -> list[PoEStatus]:
|
|
221
|
+
raise UnsupportedCapabilityError(_NO_POE)
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
"""Model-driven NSDP write/control over a write-capable sync or async client.
|
|
2
|
+
|
|
3
|
+
Parallel to ``snmp_write.py``. Every write performs the NSDP WRITE_REQUEST
|
|
4
|
+
(v1-authenticated) then re-reads to verify (``WriteVerificationError`` with
|
|
5
|
+
before/after on mismatch; a bad password / transport error surfaces as
|
|
6
|
+
``NsdpError`` from the client first). Disruptive per-port writes to a
|
|
7
|
+
``protected_ports`` port are refused unless ``force=True``. Writes NSDP has no
|
|
8
|
+
tag for (PoE, per-port admin, VLAN create/delete) raise
|
|
9
|
+
``UnsupportedCapabilityError`` — never a silent no-op.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
from .errors import (
|
|
16
|
+
ProtectedPortError,
|
|
17
|
+
UnsupportedCapabilityError,
|
|
18
|
+
WriteVerificationError,
|
|
19
|
+
)
|
|
20
|
+
from .models import VlanMode
|
|
21
|
+
from .nsdp_read import AsyncNsdpReader, NsdpReader
|
|
22
|
+
from .protocols.nsdp.protocol import Tag
|
|
23
|
+
from .protocols.nsdp.write import ipv4_tlv, pvid_tlv, vlan_members_tlv
|
|
24
|
+
from .registry import Backend
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from .models import VLANInfo
|
|
28
|
+
from .protocols.nsdp.client import AsyncNsdpWriteClient, NsdpWriteClient
|
|
29
|
+
from .registry import SwitchModel
|
|
30
|
+
from .snmp_write import PoeCycleTimeouts
|
|
31
|
+
|
|
32
|
+
_NO_POE = "NSDP has no PoE control tag; use the HTTP backend (Slice 6) for PoE"
|
|
33
|
+
_NO_PORT_ADMIN = (
|
|
34
|
+
"no per-port admin-enable is available on these Plus models: NSDP has no "
|
|
35
|
+
"admin-enable tag, and the web UI has no grounded port-enable endpoint "
|
|
36
|
+
"(UNVERIFIED-pending-capture)"
|
|
37
|
+
)
|
|
38
|
+
_NO_VLAN_LIFECYCLE = (
|
|
39
|
+
"NSDP has no VLAN create/destroy tag on these Plus models; only VLAN "
|
|
40
|
+
"membership/PVID are writable over NSDP"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _require_nsdp(model: SwitchModel) -> None:
|
|
45
|
+
if Backend.NSDP not in model.backends:
|
|
46
|
+
raise UnsupportedCapabilityError(f"model {model.key!r} has no NSDP backend")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _members_after(
|
|
50
|
+
current: VLANInfo | None, port: int, mode: VlanMode
|
|
51
|
+
) -> tuple[set[int], set[int]]:
|
|
52
|
+
"""Read-modify-write the (members, tagged) sets for one membership change."""
|
|
53
|
+
members = set(current.member_ports) if current is not None else set()
|
|
54
|
+
tagged = set(current.tagged_ports) if current is not None else set()
|
|
55
|
+
if mode is VlanMode.EXCLUDED:
|
|
56
|
+
members.discard(port)
|
|
57
|
+
tagged.discard(port)
|
|
58
|
+
else:
|
|
59
|
+
members.add(port)
|
|
60
|
+
tagged.add(port) if mode is VlanMode.TAGGED else tagged.discard(port)
|
|
61
|
+
return members, tagged
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _membership_ok(after: VLANInfo | None, port: int, mode: VlanMode) -> bool:
|
|
65
|
+
if after is None:
|
|
66
|
+
return mode is VlanMode.EXCLUDED
|
|
67
|
+
in_members = port in after.member_ports
|
|
68
|
+
in_tagged = port in after.tagged_ports
|
|
69
|
+
if mode is VlanMode.EXCLUDED:
|
|
70
|
+
return not in_members
|
|
71
|
+
return in_members and (in_tagged == (mode is VlanMode.TAGGED))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class NsdpWriter:
|
|
75
|
+
"""Synchronous NSDP write facade over one switch."""
|
|
76
|
+
|
|
77
|
+
def __init__(
|
|
78
|
+
self,
|
|
79
|
+
client: NsdpWriteClient,
|
|
80
|
+
model: SwitchModel,
|
|
81
|
+
*,
|
|
82
|
+
password: str,
|
|
83
|
+
protected_ports: frozenset[int] = frozenset(),
|
|
84
|
+
) -> None:
|
|
85
|
+
_require_nsdp(model)
|
|
86
|
+
self.client = client
|
|
87
|
+
self.model = model
|
|
88
|
+
self._password = password
|
|
89
|
+
self.protected_ports = protected_ports
|
|
90
|
+
self._reader = NsdpReader(client, model)
|
|
91
|
+
|
|
92
|
+
def _guard(self, port: int, force: bool) -> None:
|
|
93
|
+
if port in self.protected_ports and not force:
|
|
94
|
+
raise ProtectedPortError(
|
|
95
|
+
f"port {port} is protected; pass force=True to override"
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
def _vlan(self, vlan: int) -> VLANInfo | None:
|
|
99
|
+
return next((v for v in self._reader.get_vlans() if v.vlan_id == vlan), None)
|
|
100
|
+
|
|
101
|
+
def set_pvid(self, port: int, vlan: int, *, force: bool = False) -> None:
|
|
102
|
+
# UNVERIFIED pending a hardware capture: PORT_PVID (0x3000) is documented
|
|
103
|
+
# READ-ONLY in the reference spec, so a real switch may reject this write
|
|
104
|
+
# (verify-after-write below is the guard). Same house style as
|
|
105
|
+
# snmp_write.py:set_mgmt_ip's unverified-OID note.
|
|
106
|
+
self._guard(port, force)
|
|
107
|
+
before = dict(self._reader.get_pvids())
|
|
108
|
+
self.client.write([pvid_tlv(port, vlan)], password=self._password)
|
|
109
|
+
after = dict(self._reader.get_pvids())
|
|
110
|
+
if after.get(port) != vlan:
|
|
111
|
+
raise WriteVerificationError(
|
|
112
|
+
f"PVID for port {port} did not read back as {vlan}",
|
|
113
|
+
before=before.get(port), after=after.get(port),
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
def set_vlan_membership(
|
|
117
|
+
self, vlan: int, port: int, mode: VlanMode, *, force: bool = False
|
|
118
|
+
) -> None:
|
|
119
|
+
# UNVERIFIED pending a hardware capture: VLAN_MEMBERS (0x2800) is
|
|
120
|
+
# documented READ-ONLY in the reference spec, so a real switch may reject
|
|
121
|
+
# this write (verify-after-write below is the guard). Same house style as
|
|
122
|
+
# snmp_write.py:set_mgmt_ip's unverified-OID note.
|
|
123
|
+
self._guard(port, force)
|
|
124
|
+
before = self._vlan(vlan)
|
|
125
|
+
members, tagged = _members_after(before, port, mode)
|
|
126
|
+
self.client.write(
|
|
127
|
+
[vlan_members_tlv(vlan, members, tagged, self.model.port_count)],
|
|
128
|
+
password=self._password,
|
|
129
|
+
)
|
|
130
|
+
after = self._vlan(vlan)
|
|
131
|
+
if not _membership_ok(after, port, mode):
|
|
132
|
+
raise WriteVerificationError(
|
|
133
|
+
f"VLAN {vlan} membership for port {port} did not read back as {mode}",
|
|
134
|
+
before=before, after=after,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
def set_mgmt_ip(
|
|
138
|
+
self, address: str, netmask: str, gateway: str, *, force: bool = False
|
|
139
|
+
) -> None:
|
|
140
|
+
# Force-gated: a wrong management-IP write can strand the switch.
|
|
141
|
+
# UNVERIFIED pending a hardware capture: the NSDP write path + v1 auth are
|
|
142
|
+
# unconfirmed against real hardware (verify-after-write below is the
|
|
143
|
+
# guard), same house style as snmp_write.py:set_mgmt_ip. (IP/netmask/
|
|
144
|
+
# gateway are R/W in the reference spec, unlike PVID/VLAN_MEMBERS.)
|
|
145
|
+
if not force:
|
|
146
|
+
raise ProtectedPortError(
|
|
147
|
+
"set_mgmt_ip can strand the switch; pass force=True to override"
|
|
148
|
+
)
|
|
149
|
+
before = self._reader.get_mgmt_ip()
|
|
150
|
+
self.client.write(
|
|
151
|
+
[
|
|
152
|
+
ipv4_tlv(Tag.IP_ADDRESS, address),
|
|
153
|
+
ipv4_tlv(Tag.NETMASK, netmask),
|
|
154
|
+
ipv4_tlv(Tag.GATEWAY, gateway),
|
|
155
|
+
],
|
|
156
|
+
password=self._password,
|
|
157
|
+
)
|
|
158
|
+
after = self._reader.get_mgmt_ip()
|
|
159
|
+
if (after.address, after.netmask, after.gateway) != (address, netmask, gateway):
|
|
160
|
+
raise WriteVerificationError(
|
|
161
|
+
"management IP did not read back as written",
|
|
162
|
+
before=before, after=after,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
def set_poe(self, port: int, on: bool, *, force: bool = False) -> None:
|
|
166
|
+
raise UnsupportedCapabilityError(_NO_POE)
|
|
167
|
+
|
|
168
|
+
def cycle_poe(
|
|
169
|
+
self, port: int, *, force: bool = False,
|
|
170
|
+
timeouts: PoeCycleTimeouts | None = None,
|
|
171
|
+
) -> None:
|
|
172
|
+
# timeouts accepted-but-unused: matches SnmpWriter's signature so the
|
|
173
|
+
# facade's SnmpWriter | NsdpWriter union call site typechecks; NSDP has
|
|
174
|
+
# no PoE control tag at all, so there is nothing to time.
|
|
175
|
+
raise UnsupportedCapabilityError(_NO_POE)
|
|
176
|
+
|
|
177
|
+
def clear_poe_fault(
|
|
178
|
+
self, port: int, *, force: bool = False,
|
|
179
|
+
timeouts: PoeCycleTimeouts | None = None,
|
|
180
|
+
) -> None:
|
|
181
|
+
raise UnsupportedCapabilityError(_NO_POE)
|
|
182
|
+
|
|
183
|
+
def set_port_enabled(
|
|
184
|
+
self, port: int, enabled: bool, *, force: bool = False
|
|
185
|
+
) -> None:
|
|
186
|
+
raise UnsupportedCapabilityError(_NO_PORT_ADMIN)
|
|
187
|
+
|
|
188
|
+
def create_vlan(self, vlan: int, name: str, *, force: bool = False) -> None:
|
|
189
|
+
raise UnsupportedCapabilityError(_NO_VLAN_LIFECYCLE)
|
|
190
|
+
|
|
191
|
+
def delete_vlan(self, vlan: int, *, force: bool = False) -> None:
|
|
192
|
+
raise UnsupportedCapabilityError(_NO_VLAN_LIFECYCLE)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class AsyncNsdpWriter:
|
|
196
|
+
"""Asynchronous NSDP write facade (mirror of NsdpWriter)."""
|
|
197
|
+
|
|
198
|
+
def __init__(
|
|
199
|
+
self,
|
|
200
|
+
client: AsyncNsdpWriteClient,
|
|
201
|
+
model: SwitchModel,
|
|
202
|
+
*,
|
|
203
|
+
password: str,
|
|
204
|
+
protected_ports: frozenset[int] = frozenset(),
|
|
205
|
+
) -> None:
|
|
206
|
+
_require_nsdp(model)
|
|
207
|
+
self.client = client
|
|
208
|
+
self.model = model
|
|
209
|
+
self._password = password
|
|
210
|
+
self.protected_ports = protected_ports
|
|
211
|
+
self._reader = AsyncNsdpReader(client, model)
|
|
212
|
+
|
|
213
|
+
def _guard(self, port: int, force: bool) -> None:
|
|
214
|
+
if port in self.protected_ports and not force:
|
|
215
|
+
raise ProtectedPortError(
|
|
216
|
+
f"port {port} is protected; pass force=True to override"
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
async def _vlan(self, vlan: int) -> VLANInfo | None:
|
|
220
|
+
vlans = await self._reader.get_vlans()
|
|
221
|
+
return next((v for v in vlans if v.vlan_id == vlan), None)
|
|
222
|
+
|
|
223
|
+
async def set_pvid(self, port: int, vlan: int, *, force: bool = False) -> None:
|
|
224
|
+
# UNVERIFIED pending a hardware capture: PORT_PVID (0x3000) is documented
|
|
225
|
+
# READ-ONLY in the reference spec, so a real switch may reject this write
|
|
226
|
+
# (verify-after-write below is the guard). Same house style as
|
|
227
|
+
# snmp_write.py:set_mgmt_ip's unverified-OID note.
|
|
228
|
+
self._guard(port, force)
|
|
229
|
+
before = dict(await self._reader.get_pvids())
|
|
230
|
+
await self.client.write([pvid_tlv(port, vlan)], password=self._password)
|
|
231
|
+
after = dict(await self._reader.get_pvids())
|
|
232
|
+
if after.get(port) != vlan:
|
|
233
|
+
raise WriteVerificationError(
|
|
234
|
+
f"PVID for port {port} did not read back as {vlan}",
|
|
235
|
+
before=before.get(port), after=after.get(port),
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
async def set_vlan_membership(
|
|
239
|
+
self, vlan: int, port: int, mode: VlanMode, *, force: bool = False
|
|
240
|
+
) -> None:
|
|
241
|
+
# UNVERIFIED pending a hardware capture: VLAN_MEMBERS (0x2800) is
|
|
242
|
+
# documented READ-ONLY in the reference spec, so a real switch may reject
|
|
243
|
+
# this write (verify-after-write below is the guard). Same house style as
|
|
244
|
+
# snmp_write.py:set_mgmt_ip's unverified-OID note.
|
|
245
|
+
self._guard(port, force)
|
|
246
|
+
before = await self._vlan(vlan)
|
|
247
|
+
members, tagged = _members_after(before, port, mode)
|
|
248
|
+
await self.client.write(
|
|
249
|
+
[vlan_members_tlv(vlan, members, tagged, self.model.port_count)],
|
|
250
|
+
password=self._password,
|
|
251
|
+
)
|
|
252
|
+
after = await self._vlan(vlan)
|
|
253
|
+
if not _membership_ok(after, port, mode):
|
|
254
|
+
raise WriteVerificationError(
|
|
255
|
+
f"VLAN {vlan} membership for port {port} did not read back as {mode}",
|
|
256
|
+
before=before, after=after,
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
async def set_mgmt_ip(
|
|
260
|
+
self, address: str, netmask: str, gateway: str, *, force: bool = False
|
|
261
|
+
) -> None:
|
|
262
|
+
# Force-gated: a wrong management-IP write can strand the switch.
|
|
263
|
+
# UNVERIFIED pending a hardware capture: the NSDP write path + v1 auth are
|
|
264
|
+
# unconfirmed against real hardware (verify-after-write below is the
|
|
265
|
+
# guard), same house style as snmp_write.py:set_mgmt_ip. (IP/netmask/
|
|
266
|
+
# gateway are R/W in the reference spec, unlike PVID/VLAN_MEMBERS.)
|
|
267
|
+
if not force:
|
|
268
|
+
raise ProtectedPortError(
|
|
269
|
+
"set_mgmt_ip can strand the switch; pass force=True to override"
|
|
270
|
+
)
|
|
271
|
+
before = await self._reader.get_mgmt_ip()
|
|
272
|
+
await self.client.write(
|
|
273
|
+
[
|
|
274
|
+
ipv4_tlv(Tag.IP_ADDRESS, address),
|
|
275
|
+
ipv4_tlv(Tag.NETMASK, netmask),
|
|
276
|
+
ipv4_tlv(Tag.GATEWAY, gateway),
|
|
277
|
+
],
|
|
278
|
+
password=self._password,
|
|
279
|
+
)
|
|
280
|
+
after = await self._reader.get_mgmt_ip()
|
|
281
|
+
if (after.address, after.netmask, after.gateway) != (address, netmask, gateway):
|
|
282
|
+
raise WriteVerificationError(
|
|
283
|
+
"management IP did not read back as written",
|
|
284
|
+
before=before, after=after,
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
async def set_poe(self, port: int, on: bool, *, force: bool = False) -> None:
|
|
288
|
+
raise UnsupportedCapabilityError(_NO_POE)
|
|
289
|
+
|
|
290
|
+
async def cycle_poe(
|
|
291
|
+
self, port: int, *, force: bool = False,
|
|
292
|
+
timeouts: PoeCycleTimeouts | None = None,
|
|
293
|
+
) -> None:
|
|
294
|
+
# timeouts accepted-but-unused: matches AsyncSnmpWriter's signature so
|
|
295
|
+
# the facade's AsyncSnmpWriter | AsyncNsdpWriter union call site
|
|
296
|
+
# typechecks; NSDP has no PoE control tag at all, so there is nothing
|
|
297
|
+
# to time.
|
|
298
|
+
raise UnsupportedCapabilityError(_NO_POE)
|
|
299
|
+
|
|
300
|
+
async def clear_poe_fault(
|
|
301
|
+
self, port: int, *, force: bool = False,
|
|
302
|
+
timeouts: PoeCycleTimeouts | None = None,
|
|
303
|
+
) -> None:
|
|
304
|
+
raise UnsupportedCapabilityError(_NO_POE)
|
|
305
|
+
|
|
306
|
+
async def set_port_enabled(
|
|
307
|
+
self, port: int, enabled: bool, *, force: bool = False
|
|
308
|
+
) -> None:
|
|
309
|
+
raise UnsupportedCapabilityError(_NO_PORT_ADMIN)
|
|
310
|
+
|
|
311
|
+
async def create_vlan(self, vlan: int, name: str, *, force: bool = False) -> None:
|
|
312
|
+
raise UnsupportedCapabilityError(_NO_VLAN_LIFECYCLE)
|
|
313
|
+
|
|
314
|
+
async def delete_vlan(self, vlan: int, *, force: bool = False) -> None:
|
|
315
|
+
raise UnsupportedCapabilityError(_NO_VLAN_LIFECYCLE)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""SNMP protocol logic (pure, I/O-free)."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Pure (I/O-free) HTTP web-UI protocol layer: crypto, endpoints, parsers, forms."""
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Pure Netgear web-UI login crypto (no I/O).
|
|
2
|
+
|
|
3
|
+
The Plus family authenticates with ``md5(merge(password, rand))`` where
|
|
4
|
+
``rand`` is a per-page nonce scraped from the login form and ``merge``
|
|
5
|
+
interleaves the two strings character by character. GROUNDED against
|
|
6
|
+
``rcfiles/bin/netgear-smp-vlan`` and ``py_netgear_plus/netgear_crypt.py``.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def merge(str1: str, str2: str) -> str:
|
|
14
|
+
"""Interleave two strings character by character (Netgear login scheme)."""
|
|
15
|
+
out: list[str] = []
|
|
16
|
+
i = j = 0
|
|
17
|
+
while i < len(str1) or j < len(str2):
|
|
18
|
+
if i < len(str1):
|
|
19
|
+
out.append(str1[i])
|
|
20
|
+
i += 1
|
|
21
|
+
if j < len(str2):
|
|
22
|
+
out.append(str2[j])
|
|
23
|
+
j += 1
|
|
24
|
+
return "".join(out)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def merge_hash_md5(password: str, rand: str) -> str:
|
|
28
|
+
"""Return ``md5(merge(password, rand))`` as lowercase hex (Plus login hash)."""
|
|
29
|
+
return hashlib.md5(merge(password, rand).encode()).hexdigest()
|