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,112 @@
|
|
|
1
|
+
"""Pure SNMP write encoding: SET varbinds and Q-BRIDGE bitmap read-modify-write.
|
|
2
|
+
|
|
3
|
+
No I/O and transport-agnostic. ``SetVarbind`` carries a net-snmp-style type
|
|
4
|
+
letter (``i`` INTEGER, ``u`` Gauge32/unsigned, ``s`` string, ``x`` hex/octets,
|
|
5
|
+
``a`` IpAddress) that both transports map onto their own SET call. The bitmap
|
|
6
|
+
helpers do a read-modify-write so only the target port's bit changes, leaving
|
|
7
|
+
trunks and other access ports untouched (design spec §6).
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
from ...models import VlanMode
|
|
15
|
+
from .parse import decode_port_bitmap
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from collections.abc import Iterable
|
|
19
|
+
|
|
20
|
+
from ...registry import SwitchModel
|
|
21
|
+
|
|
22
|
+
SET_TYPE_LETTERS: frozenset[str] = frozenset({"i", "u", "s", "x", "a"})
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class SetVarbind:
|
|
27
|
+
"""One SNMP SET varbind: full numeric OID, value, and net-snmp type letter."""
|
|
28
|
+
|
|
29
|
+
oid: str
|
|
30
|
+
value: int | str | bytes
|
|
31
|
+
type_letter: str
|
|
32
|
+
|
|
33
|
+
def __post_init__(self) -> None:
|
|
34
|
+
if self.type_letter not in SET_TYPE_LETTERS:
|
|
35
|
+
raise ValueError(
|
|
36
|
+
f"unknown SET type letter {self.type_letter!r}; "
|
|
37
|
+
f"expected one of {sorted(SET_TYPE_LETTERS)}"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def encode_port_bitmap(ports: Iterable[int], width_bytes: int = 8) -> bytes:
|
|
42
|
+
"""Inverse of ``parse.decode_port_bitmap``: a port set -> a wire bitmap.
|
|
43
|
+
|
|
44
|
+
Bit 7 (MSB) of byte 0 is port 1. The buffer grows past ``width_bytes`` if a
|
|
45
|
+
port number needs it, so callers never pre-size for the actual port count.
|
|
46
|
+
"""
|
|
47
|
+
data = bytearray(width_bytes)
|
|
48
|
+
for p in ports:
|
|
49
|
+
byte_idx, bit = divmod(p - 1, 8)
|
|
50
|
+
while byte_idx >= len(data):
|
|
51
|
+
data.append(0)
|
|
52
|
+
data[byte_idx] |= 0x80 >> bit
|
|
53
|
+
return bytes(data)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def set_port_bit(
|
|
57
|
+
current: bytes | str, port: int, present: bool, *, width_bytes: int | None = None
|
|
58
|
+
) -> bytes:
|
|
59
|
+
"""Read-modify-write one port's bit in a VLAN bitmap; all others preserved.
|
|
60
|
+
|
|
61
|
+
Preserves the input bitmap's byte width to avoid wire-length mismatches on
|
|
62
|
+
SET for >64-port switches. ``width_bytes``, if given (e.g. a model-derived
|
|
63
|
+
width from ``vlan_bitmap_width``), is honoured too: the result is at least
|
|
64
|
+
8 bytes, at least as wide as the input, and at least ``width_bytes`` wide.
|
|
65
|
+
"""
|
|
66
|
+
# Compute the current bitmap's width in bytes
|
|
67
|
+
if isinstance(current, bytes):
|
|
68
|
+
current_width = len(current)
|
|
69
|
+
else:
|
|
70
|
+
current_width = len(current.encode("latin-1"))
|
|
71
|
+
|
|
72
|
+
ports = set(decode_port_bitmap(current))
|
|
73
|
+
if present:
|
|
74
|
+
ports.add(port)
|
|
75
|
+
else:
|
|
76
|
+
ports.discard(port)
|
|
77
|
+
target_width = max(8, current_width, width_bytes or 0)
|
|
78
|
+
return encode_port_bitmap(ports, width_bytes=target_width)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def membership_bitmaps(
|
|
82
|
+
*,
|
|
83
|
+
mode: VlanMode,
|
|
84
|
+
port: int,
|
|
85
|
+
egress: bytes | str,
|
|
86
|
+
untagged: bytes | str,
|
|
87
|
+
width_bytes: int | None = None,
|
|
88
|
+
) -> tuple[bytes, bytes]:
|
|
89
|
+
"""Compute (new_egress, new_untagged) for one port's VLAN membership change.
|
|
90
|
+
|
|
91
|
+
UNTAGGED -> egress bit on + untagged bit on; TAGGED -> egress on, untagged
|
|
92
|
+
off; EXCLUDED -> both off. Read-modify-write on the current bitmaps, so
|
|
93
|
+
every other port's membership is preserved. ``width_bytes`` is forwarded to
|
|
94
|
+
``set_port_bit`` for both columns (see ``vlan_bitmap_width``).
|
|
95
|
+
"""
|
|
96
|
+
in_egress = mode in (VlanMode.UNTAGGED, VlanMode.TAGGED)
|
|
97
|
+
in_untagged = mode is VlanMode.UNTAGGED
|
|
98
|
+
return (
|
|
99
|
+
set_port_bit(egress, port, in_egress, width_bytes=width_bytes),
|
|
100
|
+
set_port_bit(untagged, port, in_untagged, width_bytes=width_bytes),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def vlan_bitmap_width(model: SwitchModel) -> int:
|
|
105
|
+
"""Wire byte-width of ``model``'s dot1q VLAN egress/untagged bitmaps.
|
|
106
|
+
|
|
107
|
+
``dot1qVlanStaticEgressPorts``/``UntaggedPorts`` are packed 8 ports/byte,
|
|
108
|
+
MSB-first (port 1 = bit 7 of byte 0). The Q-BRIDGE MIB's own default
|
|
109
|
+
PortList width is 8 bytes (64 ports); a model with more ports needs a
|
|
110
|
+
wider bitmap or the SET's wire length won't match what the device expects.
|
|
111
|
+
"""
|
|
112
|
+
return max(8, (model.port_count + 7) // 8)
|
netgear_switch/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Declarative registry of known Netgear switch models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import enum
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from types import MappingProxyType
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from .errors import UnknownModelError
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from collections.abc import Mapping
|
|
14
|
+
|
|
15
|
+
_FM = "1.3.6.1.4.1.4526.10" # Fully Managed vendor subtree (M4300, GSM7252PS)
|
|
16
|
+
_SMP = "1.3.6.1.4.1.4526.11" # Smart Managed Pro vendor subtree (S3300/GSM7228PS)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Backend(enum.Enum):
|
|
20
|
+
SNMP = "snmp"
|
|
21
|
+
NSDP = "nsdp"
|
|
22
|
+
HTTP = "http"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SwitchClass(enum.Enum):
|
|
26
|
+
FULLY_MANAGED = "fully_managed"
|
|
27
|
+
SMART_MANAGED_PRO = "smart_managed_pro"
|
|
28
|
+
PLUS = "plus"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class SwitchModel:
|
|
33
|
+
key: str
|
|
34
|
+
display_name: str
|
|
35
|
+
switch_class: SwitchClass
|
|
36
|
+
port_count: int
|
|
37
|
+
poe_port_count: int
|
|
38
|
+
backends: frozenset[Backend]
|
|
39
|
+
snmp_vendor_base: str | None
|
|
40
|
+
# True (the default) for every model with a real device capture or other
|
|
41
|
+
# hardware-validated prior art backing its fields. False marks a model
|
|
42
|
+
# registered from spec sheets/product briefs alone, with NO capture --
|
|
43
|
+
# its port/PoE counts and (for SNMP models) vendor OID family are a
|
|
44
|
+
# best-effort guess, and vendor-specific reads (get_sensors, vendor PoE
|
|
45
|
+
# power, etc.) are UNVERIFIED-pending-capture even though the
|
|
46
|
+
# model-agnostic standard-MIB/CGI reads should still work. See the
|
|
47
|
+
# UNVERIFIED-pending-capture entries below (m7300, xs748t, gs728tpp) for
|
|
48
|
+
# the honesty rationale; do NOT flip this to True without a real capture.
|
|
49
|
+
verified: bool = True
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def has_mac_table(self) -> bool:
|
|
53
|
+
# MAC/FDB table is only reachable via SNMP (managed switches).
|
|
54
|
+
return Backend.SNMP in self.backends
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _model(
|
|
58
|
+
key: str,
|
|
59
|
+
display_name: str,
|
|
60
|
+
switch_class: SwitchClass,
|
|
61
|
+
port_count: int,
|
|
62
|
+
poe_port_count: int,
|
|
63
|
+
backends: set[Backend],
|
|
64
|
+
snmp_vendor_base: str | None,
|
|
65
|
+
*,
|
|
66
|
+
verified: bool = True,
|
|
67
|
+
) -> SwitchModel:
|
|
68
|
+
return SwitchModel(
|
|
69
|
+
key=key,
|
|
70
|
+
display_name=display_name,
|
|
71
|
+
switch_class=switch_class,
|
|
72
|
+
port_count=port_count,
|
|
73
|
+
poe_port_count=poe_port_count,
|
|
74
|
+
backends=frozenset(backends),
|
|
75
|
+
snmp_vendor_base=snmp_vendor_base,
|
|
76
|
+
verified=verified,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
_MODELS: dict[str, SwitchModel] = {
|
|
81
|
+
m.key: m
|
|
82
|
+
for m in (
|
|
83
|
+
_model(
|
|
84
|
+
"m4300-24x",
|
|
85
|
+
"M4300-24X (XSM4324CS)",
|
|
86
|
+
SwitchClass.FULLY_MANAGED,
|
|
87
|
+
28,
|
|
88
|
+
0,
|
|
89
|
+
{Backend.SNMP},
|
|
90
|
+
_FM,
|
|
91
|
+
),
|
|
92
|
+
_model(
|
|
93
|
+
"m4300-16x",
|
|
94
|
+
"M4300-16X (XSM4316)",
|
|
95
|
+
SwitchClass.FULLY_MANAGED,
|
|
96
|
+
16,
|
|
97
|
+
16,
|
|
98
|
+
{Backend.SNMP},
|
|
99
|
+
_FM,
|
|
100
|
+
),
|
|
101
|
+
_model(
|
|
102
|
+
"gsm7252ps",
|
|
103
|
+
"GSM7252PS",
|
|
104
|
+
SwitchClass.FULLY_MANAGED,
|
|
105
|
+
52,
|
|
106
|
+
48,
|
|
107
|
+
{Backend.SNMP},
|
|
108
|
+
_FM,
|
|
109
|
+
),
|
|
110
|
+
_model(
|
|
111
|
+
"gsm7228ps",
|
|
112
|
+
"GSM7228PS (S3300)",
|
|
113
|
+
SwitchClass.SMART_MANAGED_PRO,
|
|
114
|
+
52,
|
|
115
|
+
48,
|
|
116
|
+
{Backend.SNMP, Backend.HTTP},
|
|
117
|
+
_SMP,
|
|
118
|
+
),
|
|
119
|
+
_model(
|
|
120
|
+
"gs110emx",
|
|
121
|
+
"GS110EMX",
|
|
122
|
+
SwitchClass.PLUS,
|
|
123
|
+
10,
|
|
124
|
+
0,
|
|
125
|
+
{Backend.NSDP, Backend.HTTP},
|
|
126
|
+
None,
|
|
127
|
+
),
|
|
128
|
+
_model(
|
|
129
|
+
"gs305ep",
|
|
130
|
+
"GS305EP",
|
|
131
|
+
SwitchClass.PLUS,
|
|
132
|
+
5,
|
|
133
|
+
4,
|
|
134
|
+
{Backend.NSDP, Backend.HTTP},
|
|
135
|
+
None,
|
|
136
|
+
),
|
|
137
|
+
# --- UNVERIFIED-pending-capture below: no device capture exists for
|
|
138
|
+
# any of these three models (gdoc2netcfg fleet models with no
|
|
139
|
+
# prior-art fixture). Registered from spec sheets/product briefs only
|
|
140
|
+
# so gdoc2netcfg can construct a SyncSwitch for them; see each
|
|
141
|
+
# entry's comment for what specifically is a guess. The
|
|
142
|
+
# model-agnostic standard-MIB SNMP reads (ports/vlans/lldp/PoE
|
|
143
|
+
# admin/stats/mgmt-IP) should work regardless of the vendor OID
|
|
144
|
+
# family guess below, but get_sensors() and vendor PoE-power
|
|
145
|
+
# readings are UNVERIFIED until a real capture confirms the
|
|
146
|
+
# 4526.10 vs 4526.11 subtree. Do NOT treat any of these three as a
|
|
147
|
+
# source of confirmed behaviour -- confirm via hardware
|
|
148
|
+
# verification before relying on anything beyond the standard MIBs.
|
|
149
|
+
_model(
|
|
150
|
+
"m7300",
|
|
151
|
+
# M7300-24XF (24x SFP+, 0 PoE) picked as the assumed/documented
|
|
152
|
+
# variant -- the M7300 family also ships non-XF and other port
|
|
153
|
+
# counts; which exact SKU gdoc2netcfg's fleet actually runs is
|
|
154
|
+
# UNVERIFIED. Same FASTPATH fully-managed lineage as M4300, so
|
|
155
|
+
# the 4526.10 ("_FM") vendor subtree is the best spec-guess, but
|
|
156
|
+
# that family assignment is itself UNVERIFIED-pending-capture.
|
|
157
|
+
"M7300-24XF",
|
|
158
|
+
SwitchClass.FULLY_MANAGED,
|
|
159
|
+
24,
|
|
160
|
+
0,
|
|
161
|
+
{Backend.SNMP},
|
|
162
|
+
_FM,
|
|
163
|
+
verified=False,
|
|
164
|
+
),
|
|
165
|
+
_model(
|
|
166
|
+
"xs748t",
|
|
167
|
+
# XS748T: 48x 10G copper (+ SFP+ combo), non-PoE per the
|
|
168
|
+
# documented base spec -- UNVERIFIED-pending-capture. HTTP is
|
|
169
|
+
# plausible for a Smart Managed Pro switch but is deliberately
|
|
170
|
+
# OMITTED here (not just unverified): see gsm7228ps for the
|
|
171
|
+
# SNMP+HTTP shape once a login/read flow is actually captured.
|
|
172
|
+
# Until then SNMP-only avoids implying a web-UI integration that
|
|
173
|
+
# does not exist in this codebase.
|
|
174
|
+
"XS748T",
|
|
175
|
+
SwitchClass.SMART_MANAGED_PRO,
|
|
176
|
+
48,
|
|
177
|
+
0,
|
|
178
|
+
{Backend.SNMP},
|
|
179
|
+
_SMP,
|
|
180
|
+
verified=False,
|
|
181
|
+
),
|
|
182
|
+
_model(
|
|
183
|
+
"gs728tpp",
|
|
184
|
+
# GS728TPP: 24x Gigabit PoE+ + 4x SFP combo = 28 total ports,
|
|
185
|
+
# 24 PoE+ -- UNVERIFIED-pending-capture (port split assumed from
|
|
186
|
+
# the product name's "28" port count and Gigabit PoE+ line
|
|
187
|
+
# convention, not a capture).
|
|
188
|
+
#
|
|
189
|
+
# HTTP backend deliberately OMITTED even though this model's
|
|
190
|
+
# web UI is real and reachable: certbot-hook-netgear-switches/
|
|
191
|
+
# netgear-updater.py's GS728TPPUpdater (grounded prior art) shows
|
|
192
|
+
# GS728TPP uses a THIRD, distinct login scheme -- a GET / redirect
|
|
193
|
+
# to a per-session path, then GET {path}/System.xml?action=login&
|
|
194
|
+
# user=...&password=... (not a POST), with userStatus/usernme/
|
|
195
|
+
# sessionID cookies set from the response rather than via a
|
|
196
|
+
# normal Set-Cookie login POST. That is neither MERGE_HASH_CGI,
|
|
197
|
+
# GAMBIT, nor CHEETAH_FORM (registry.protocols.http.endpoints
|
|
198
|
+
# .LoginScheme), and transport/http/client.py's login() only
|
|
199
|
+
# knows how to drive those three. Registering Backend.HTTP here
|
|
200
|
+
# without real scheme/transport support would either (a) fail
|
|
201
|
+
# tests/protocols/http/test_endpoints.py::
|
|
202
|
+
# test_every_http_model_has_a_spec, or (b) force picking an
|
|
203
|
+
# existing (wrong) LoginScheme and have login() attempt a
|
|
204
|
+
# garbage POST against a real switch -- both dishonest. Wiring
|
|
205
|
+
# in a real LoginScheme.XML_API is a dedicated future slice, not
|
|
206
|
+
# a registry-only change. SNMP-only here is sufficient for
|
|
207
|
+
# gdoc2netcfg's SyncSwitch construction; only the SNMP vendor
|
|
208
|
+
# OID family is UNVERIFIED-pending-capture.
|
|
209
|
+
"GS728TPP",
|
|
210
|
+
SwitchClass.SMART_MANAGED_PRO,
|
|
211
|
+
28,
|
|
212
|
+
24,
|
|
213
|
+
{Backend.SNMP},
|
|
214
|
+
_SMP,
|
|
215
|
+
verified=False,
|
|
216
|
+
),
|
|
217
|
+
)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
MODELS: Mapping[str, SwitchModel] = MappingProxyType(_MODELS)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def get_model(key: str) -> SwitchModel:
|
|
224
|
+
try:
|
|
225
|
+
return _MODELS[key]
|
|
226
|
+
except KeyError:
|
|
227
|
+
raise UnknownModelError(f"unknown switch model: {key!r}") from None
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# src/netgear_switch/snmp_read.py
|
|
2
|
+
"""Model-driven SNMP read operations over a sync or async client."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from .errors import UnsupportedCapabilityError
|
|
8
|
+
from .models import DetectedModel
|
|
9
|
+
from .protocols.snmp import oids, parse
|
|
10
|
+
from .registry import MODELS, Backend
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
# Only used in type annotations (return types / parameter types), never
|
|
14
|
+
# instantiated or referenced at runtime here -- kept behind
|
|
15
|
+
# TYPE_CHECKING so ruff's TC rules stay clean (see oids.py for the same
|
|
16
|
+
# pattern).
|
|
17
|
+
from .models import (
|
|
18
|
+
LLDPNeighbor,
|
|
19
|
+
MacEntry,
|
|
20
|
+
MgmtIpConfig,
|
|
21
|
+
PoEStatus,
|
|
22
|
+
PortStats,
|
|
23
|
+
PortStatus,
|
|
24
|
+
Sensor,
|
|
25
|
+
VLANInfo,
|
|
26
|
+
)
|
|
27
|
+
from .protocols.snmp.client import AsyncSnmpClient, SnmpClient
|
|
28
|
+
from .registry import SwitchModel
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _require_snmp(model: SwitchModel) -> None:
|
|
32
|
+
if Backend.SNMP not in model.backends:
|
|
33
|
+
raise UnsupportedCapabilityError(f"model {model.key!r} has no SNMP backend")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def read_system_info(client: SnmpClient) -> DetectedModel:
|
|
37
|
+
"""Identify a switch's model via SNMP sysDescr matching.
|
|
38
|
+
|
|
39
|
+
Deliberately NOT a method on ``SnmpReader``: every other read in this
|
|
40
|
+
module requires a already-known ``SwitchModel`` (``_require_snmp`` gates
|
|
41
|
+
the reader's construction), but model identification exists precisely
|
|
42
|
+
for the case where the caller does NOT yet know/trust the model -- so
|
|
43
|
+
this takes a bare, unbound ``SnmpClient`` instead. See
|
|
44
|
+
``protocols.snmp.parse.detect_model_from_sysdescr`` for the honesty-
|
|
45
|
+
constrained matching rules (never guesses; ``key=None`` means genuinely
|
|
46
|
+
unidentified -- an unregistered model or a non-Netgear device).
|
|
47
|
+
"""
|
|
48
|
+
rows = client.get([oids.SYS_DESCR, oids.SYS_OBJECT_ID])
|
|
49
|
+
sys_descr, sys_object_id = parse.parse_system_info(rows)
|
|
50
|
+
key = parse.detect_model_from_sysdescr(sys_descr, MODELS)
|
|
51
|
+
return DetectedModel(key=key, sys_descr=sys_descr, sys_object_id=sys_object_id)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
async def async_read_system_info(client: AsyncSnmpClient) -> DetectedModel:
|
|
55
|
+
"""Async twin of ``read_system_info`` -- see there."""
|
|
56
|
+
rows = await client.get([oids.SYS_DESCR, oids.SYS_OBJECT_ID])
|
|
57
|
+
sys_descr, sys_object_id = parse.parse_system_info(rows)
|
|
58
|
+
key = parse.detect_model_from_sysdescr(sys_descr, MODELS)
|
|
59
|
+
return DetectedModel(key=key, sys_descr=sys_descr, sys_object_id=sys_object_id)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class SnmpReader:
|
|
63
|
+
def __init__(self, client: SnmpClient, model: SwitchModel) -> None:
|
|
64
|
+
# _require_snmp is the single capability gate: it raises for any model
|
|
65
|
+
# without an SNMP backend (i.e. Plus). Vendor OIDs are resolved lazily,
|
|
66
|
+
# only in the ops that need the vendor subtree (get_poe/get_sensors/
|
|
67
|
+
# get_mgmt_ip), so constructing a reader never touches vendor_oids.
|
|
68
|
+
_require_snmp(model)
|
|
69
|
+
self.client = client
|
|
70
|
+
self.model = model
|
|
71
|
+
|
|
72
|
+
def get_ports(self) -> list[PortStatus]:
|
|
73
|
+
w = self.client.walk
|
|
74
|
+
return parse.parse_port_status(
|
|
75
|
+
w(oids.IF_ADMIN_STATUS), w(oids.IF_OPER_STATUS),
|
|
76
|
+
w(oids.IF_HIGH_SPEED), w(oids.IF_NAME), w(oids.IF_ALIAS),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
def get_stats(self) -> list[PortStats]:
|
|
80
|
+
w = self.client.walk
|
|
81
|
+
return parse.parse_port_stats(
|
|
82
|
+
in_octets=w(oids.IF_HC_IN_OCTETS), out_octets=w(oids.IF_HC_OUT_OCTETS),
|
|
83
|
+
in_ucast=w(oids.IF_HC_IN_UCAST), out_ucast=w(oids.IF_HC_OUT_UCAST),
|
|
84
|
+
in_errors=w(oids.IF_IN_ERRORS), out_errors=w(oids.IF_OUT_ERRORS),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
def get_vlans(self) -> list[VLANInfo]:
|
|
88
|
+
w = self.client.walk
|
|
89
|
+
return parse.parse_vlans(
|
|
90
|
+
w(oids.DOT1Q_VLAN_STATIC_NAME), w(oids.DOT1Q_VLAN_STATIC_EGRESS),
|
|
91
|
+
w(oids.DOT1Q_VLAN_STATIC_UNTAGGED),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
def get_pvids(self) -> list[tuple[int, int]]:
|
|
95
|
+
return parse.parse_pvids(self.client.walk(oids.DOT1Q_PVID))
|
|
96
|
+
|
|
97
|
+
def get_lldp(self) -> list[LLDPNeighbor]:
|
|
98
|
+
return parse.parse_lldp(self.client.walk(oids.LLDP_REM_TABLE))
|
|
99
|
+
|
|
100
|
+
def get_macs(self) -> list[MacEntry]:
|
|
101
|
+
# No has_mac_table guard here: has_mac_table == (Backend.SNMP in
|
|
102
|
+
# backends), which __init__'s _require_snmp already enforced. (The
|
|
103
|
+
# registry.has_mac_table property stays for external callers.)
|
|
104
|
+
w = self.client.walk
|
|
105
|
+
return parse.parse_macs(
|
|
106
|
+
w(oids.DOT1Q_TP_FDB_PORT), w(oids.DOT1D_BASE_PORT_IF_INDEX)
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
def get_poe(self) -> list[PoEStatus]:
|
|
110
|
+
vendor = oids.vendor_oids(self.model)
|
|
111
|
+
w = self.client.walk
|
|
112
|
+
return parse.parse_poe(
|
|
113
|
+
w(oids.PETH_PSE_PORT_TABLE), w(vendor.poe_power_mw)
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
def get_sensors(self) -> list[Sensor]:
|
|
117
|
+
vendor = oids.vendor_oids(self.model)
|
|
118
|
+
w = self.client.walk
|
|
119
|
+
columns = [
|
|
120
|
+
("fan", "RPM", w(vendor.box_fan)),
|
|
121
|
+
("power", "W", w(vendor.box_psu_power)),
|
|
122
|
+
("temperature", "C", w(vendor.box_temp)),
|
|
123
|
+
]
|
|
124
|
+
return parse.parse_box_sensors(columns)
|
|
125
|
+
|
|
126
|
+
def get_mgmt_ip(self) -> MgmtIpConfig:
|
|
127
|
+
vendor = oids.vendor_oids(self.model)
|
|
128
|
+
w = self.client.walk
|
|
129
|
+
return parse.parse_mgmt_ip(
|
|
130
|
+
w(oids.IP_ADENT_ADDR), w(oids.IP_ADENT_NETMASK),
|
|
131
|
+
w(oids.IP_ROUTE_DEST), w(oids.IP_ROUTE_NEXTHOP),
|
|
132
|
+
w(vendor.dhcp_mode_unverified), # single named UNVERIFIED OID (Task 4)
|
|
133
|
+
w(oids.DOT1D_BASE_BRIDGE_ADDRESS), # standard BRIDGE-MIB scalar
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
def get_system_info(self) -> DetectedModel:
|
|
137
|
+
"""Identify this switch's model via sysDescr (see ``read_system_info``).
|
|
138
|
+
|
|
139
|
+
Reuses this reader's already-connected client. Unlike every other
|
|
140
|
+
method here, the result does NOT depend on ``self.model`` matching
|
|
141
|
+
the real device -- useful to confirm/discover a switch's real model
|
|
142
|
+
via a reader that was (possibly wrongly) constructed against a
|
|
143
|
+
different model key.
|
|
144
|
+
"""
|
|
145
|
+
return read_system_info(self.client)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class AsyncSnmpReader:
|
|
149
|
+
def __init__(self, client: AsyncSnmpClient, model: SwitchModel) -> None:
|
|
150
|
+
# Same contract as SnmpReader: _require_snmp gates construction; vendor
|
|
151
|
+
# OIDs resolved lazily in get_poe/get_sensors/get_mgmt_ip only.
|
|
152
|
+
_require_snmp(model)
|
|
153
|
+
self.client = client
|
|
154
|
+
self.model = model
|
|
155
|
+
|
|
156
|
+
async def get_ports(self) -> list[PortStatus]:
|
|
157
|
+
w = self.client.walk
|
|
158
|
+
return parse.parse_port_status(
|
|
159
|
+
await w(oids.IF_ADMIN_STATUS), await w(oids.IF_OPER_STATUS),
|
|
160
|
+
await w(oids.IF_HIGH_SPEED), await w(oids.IF_NAME),
|
|
161
|
+
await w(oids.IF_ALIAS),
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
async def get_stats(self) -> list[PortStats]:
|
|
165
|
+
w = self.client.walk
|
|
166
|
+
return parse.parse_port_stats(
|
|
167
|
+
in_octets=await w(oids.IF_HC_IN_OCTETS),
|
|
168
|
+
out_octets=await w(oids.IF_HC_OUT_OCTETS),
|
|
169
|
+
in_ucast=await w(oids.IF_HC_IN_UCAST),
|
|
170
|
+
out_ucast=await w(oids.IF_HC_OUT_UCAST),
|
|
171
|
+
in_errors=await w(oids.IF_IN_ERRORS),
|
|
172
|
+
out_errors=await w(oids.IF_OUT_ERRORS),
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
async def get_vlans(self) -> list[VLANInfo]:
|
|
176
|
+
w = self.client.walk
|
|
177
|
+
return parse.parse_vlans(
|
|
178
|
+
await w(oids.DOT1Q_VLAN_STATIC_NAME),
|
|
179
|
+
await w(oids.DOT1Q_VLAN_STATIC_EGRESS),
|
|
180
|
+
await w(oids.DOT1Q_VLAN_STATIC_UNTAGGED),
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
async def get_pvids(self) -> list[tuple[int, int]]:
|
|
184
|
+
return parse.parse_pvids(await self.client.walk(oids.DOT1Q_PVID))
|
|
185
|
+
|
|
186
|
+
async def get_lldp(self) -> list[LLDPNeighbor]:
|
|
187
|
+
return parse.parse_lldp(await self.client.walk(oids.LLDP_REM_TABLE))
|
|
188
|
+
|
|
189
|
+
async def get_macs(self) -> list[MacEntry]:
|
|
190
|
+
# No has_mac_table guard: _require_snmp in __init__ already enforced it.
|
|
191
|
+
w = self.client.walk
|
|
192
|
+
return parse.parse_macs(
|
|
193
|
+
await w(oids.DOT1Q_TP_FDB_PORT),
|
|
194
|
+
await w(oids.DOT1D_BASE_PORT_IF_INDEX),
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
async def get_poe(self) -> list[PoEStatus]:
|
|
198
|
+
vendor = oids.vendor_oids(self.model)
|
|
199
|
+
w = self.client.walk
|
|
200
|
+
return parse.parse_poe(
|
|
201
|
+
await w(oids.PETH_PSE_PORT_TABLE), await w(vendor.poe_power_mw)
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
async def get_sensors(self) -> list[Sensor]:
|
|
205
|
+
vendor = oids.vendor_oids(self.model)
|
|
206
|
+
w = self.client.walk
|
|
207
|
+
columns = [
|
|
208
|
+
("fan", "RPM", await w(vendor.box_fan)),
|
|
209
|
+
("power", "W", await w(vendor.box_psu_power)),
|
|
210
|
+
("temperature", "C", await w(vendor.box_temp)),
|
|
211
|
+
]
|
|
212
|
+
return parse.parse_box_sensors(columns)
|
|
213
|
+
|
|
214
|
+
async def get_mgmt_ip(self) -> MgmtIpConfig:
|
|
215
|
+
vendor = oids.vendor_oids(self.model)
|
|
216
|
+
w = self.client.walk
|
|
217
|
+
return parse.parse_mgmt_ip(
|
|
218
|
+
await w(oids.IP_ADENT_ADDR), await w(oids.IP_ADENT_NETMASK),
|
|
219
|
+
await w(oids.IP_ROUTE_DEST), await w(oids.IP_ROUTE_NEXTHOP),
|
|
220
|
+
await w(vendor.dhcp_mode_unverified), # single named UNVERIFIED OID
|
|
221
|
+
await w(oids.DOT1D_BASE_BRIDGE_ADDRESS), # standard BRIDGE-MIB scalar
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
async def get_system_info(self) -> DetectedModel:
|
|
225
|
+
"""Async twin of ``SnmpReader.get_system_info`` -- see there."""
|
|
226
|
+
return await async_read_system_info(self.client)
|