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,529 @@
|
|
|
1
|
+
"""Public asynchronous read/write facade: AsyncSwitch (mirror of SyncSwitch)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
from typing import TYPE_CHECKING, Any, TypeVar
|
|
6
|
+
|
|
7
|
+
from ._dispatch import (
|
|
8
|
+
build_async_http_client,
|
|
9
|
+
build_async_nsdp_client,
|
|
10
|
+
build_async_snmp_client,
|
|
11
|
+
build_async_snmp_write_client,
|
|
12
|
+
http_reads_supported,
|
|
13
|
+
require_mac_table,
|
|
14
|
+
)
|
|
15
|
+
from .errors import CredentialError, ProtectedPortError, UnsupportedCapabilityError
|
|
16
|
+
from .http_read import AsyncHttpReader
|
|
17
|
+
from .http_write import AsyncHttpWriter
|
|
18
|
+
from .models import SwitchData
|
|
19
|
+
from .nsdp_read import AsyncNsdpReader
|
|
20
|
+
from .nsdp_write import AsyncNsdpWriter
|
|
21
|
+
from .registry import Backend
|
|
22
|
+
from .snmp_read import AsyncSnmpReader, async_read_system_info
|
|
23
|
+
from .snmp_write import AsyncSnmpWriter, PoeCycleTimeouts
|
|
24
|
+
|
|
25
|
+
_DEFAULT_POE_TIMEOUTS = PoeCycleTimeouts()
|
|
26
|
+
|
|
27
|
+
_R = TypeVar("_R")
|
|
28
|
+
# Per-op backend preference: try SNMP, then NSDP, then HTTP; the first backend
|
|
29
|
+
# whose reader/writer serves an op wins. HTTP only ever fills the gaps the
|
|
30
|
+
# higher-priority backends raise UnsupportedCapabilityError for.
|
|
31
|
+
_BACKEND_PREFERENCE = (Backend.SNMP, Backend.NSDP, Backend.HTTP)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class _Unset:
|
|
35
|
+
"""Sentinel type for "write community not yet resolved" (see
|
|
36
|
+
AsyncSwitch._resolved_write_community): a resolved value of None (no
|
|
37
|
+
community configured) must stay distinguishable from "never resolved"."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
_UNSET = _Unset()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class _LazyAsyncHttpSession:
|
|
44
|
+
"""Async mirror of ``sync_api._LazyHttpSession``: defers building the real
|
|
45
|
+
AsyncHttpSession (which needs a resolved password) until an op that
|
|
46
|
+
genuinely reaches the wire (``login``/``get_page``/``post_form``) is
|
|
47
|
+
called. Ops an AsyncHttpReader/AsyncHttpWriter refuses honestly WITHOUT
|
|
48
|
+
ever touching the session (e.g. ``get_macs``, ``set_mgmt_ip``) must never
|
|
49
|
+
trigger HTTP password resolution or a live connection."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, resolve: Callable[[], AsyncHttpSession]) -> None:
|
|
52
|
+
self._resolve = resolve
|
|
53
|
+
|
|
54
|
+
async def login(self) -> None:
|
|
55
|
+
await self._resolve().login()
|
|
56
|
+
|
|
57
|
+
async def get_page(self, path: str) -> str:
|
|
58
|
+
return await self._resolve().get_page(path)
|
|
59
|
+
|
|
60
|
+
async def post_form(self, path: str, data: dict[str, str]) -> str:
|
|
61
|
+
return await self._resolve().post_form(path, data)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
if TYPE_CHECKING:
|
|
65
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
66
|
+
from types import TracebackType
|
|
67
|
+
from typing import Self
|
|
68
|
+
|
|
69
|
+
from .config import SwitchConfig
|
|
70
|
+
from .models import (
|
|
71
|
+
DetectedModel,
|
|
72
|
+
LLDPNeighbor,
|
|
73
|
+
MacEntry,
|
|
74
|
+
MgmtIpConfig,
|
|
75
|
+
PoEStatus,
|
|
76
|
+
PortStats,
|
|
77
|
+
PortStatus,
|
|
78
|
+
Sensor,
|
|
79
|
+
VLANInfo,
|
|
80
|
+
VlanMode,
|
|
81
|
+
)
|
|
82
|
+
from .protocols.http.session import AsyncHttpSession
|
|
83
|
+
from .protocols.nsdp.client import AsyncNsdpClient, AsyncNsdpWriteClient
|
|
84
|
+
from .protocols.nsdp.types import NsdpDevice
|
|
85
|
+
from .protocols.snmp.client import AsyncSnmpClient, AsyncSnmpWriteClient
|
|
86
|
+
from .registry import SwitchModel
|
|
87
|
+
from .transport.http.client import AsyncHttpClient
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
async def async_detect_model(
|
|
91
|
+
host: str, *, community: str | None = None, client: AsyncSnmpClient | None = None
|
|
92
|
+
) -> DetectedModel:
|
|
93
|
+
"""Async twin of ``sync_api.detect_model`` -- see there."""
|
|
94
|
+
if client is None:
|
|
95
|
+
client = build_async_snmp_client(host, community)
|
|
96
|
+
return await async_read_system_info(client)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class AsyncSwitch:
|
|
100
|
+
"""Asynchronous, model-driven read/write facade over one switch."""
|
|
101
|
+
|
|
102
|
+
def __init__(
|
|
103
|
+
self,
|
|
104
|
+
model: SwitchModel,
|
|
105
|
+
host: str,
|
|
106
|
+
*,
|
|
107
|
+
snmp_community: str | None = None,
|
|
108
|
+
snmp_client: AsyncSnmpClient | None = None,
|
|
109
|
+
snmp_write_community: str | None = None,
|
|
110
|
+
snmp_write_client: AsyncSnmpWriteClient | None = None,
|
|
111
|
+
snmp_write_community_resolver: Callable[[], str | None] | None = None,
|
|
112
|
+
nsdp_interface: str | None = None,
|
|
113
|
+
nsdp_client: AsyncNsdpClient | None = None,
|
|
114
|
+
nsdp_write_client: AsyncNsdpWriteClient | None = None,
|
|
115
|
+
nsdp_password: str | None = None,
|
|
116
|
+
nsdp_password_resolver: Callable[[], str | None] | None = None,
|
|
117
|
+
http_client: AsyncHttpSession | None = None,
|
|
118
|
+
http_password: str | None = None,
|
|
119
|
+
http_password_resolver: Callable[[], str | None] | None = None,
|
|
120
|
+
protected_ports: frozenset[int] = frozenset(),
|
|
121
|
+
) -> None:
|
|
122
|
+
self.model = model
|
|
123
|
+
self.host = host
|
|
124
|
+
self._snmp_community = snmp_community
|
|
125
|
+
self._snmp_client = snmp_client
|
|
126
|
+
self._snmp_write_community = snmp_write_community
|
|
127
|
+
self._snmp_write_client = snmp_write_client
|
|
128
|
+
# Deferred write-community resolution: from_config stashes a closure here
|
|
129
|
+
# instead of resolving eagerly, so read-only construction never raises a
|
|
130
|
+
# CredentialError for an unresolvable write-community spec (review item 4).
|
|
131
|
+
self._snmp_write_community_resolver = snmp_write_community_resolver
|
|
132
|
+
# Sentinel meaning "not yet resolved"; distinct from a resolved value
|
|
133
|
+
# of None (no community configured) so we only ever resolve once.
|
|
134
|
+
self._resolved_write_community: str | None | _Unset = _UNSET
|
|
135
|
+
self._nsdp_interface = nsdp_interface
|
|
136
|
+
self._nsdp_client = nsdp_client
|
|
137
|
+
self._nsdp_write_client = nsdp_write_client
|
|
138
|
+
self._nsdp_password = nsdp_password
|
|
139
|
+
self._nsdp_password_resolver = nsdp_password_resolver
|
|
140
|
+
self._resolved_nsdp_password: str | None | _Unset = _UNSET
|
|
141
|
+
self._http_client = http_client
|
|
142
|
+
self._http_password = http_password
|
|
143
|
+
self._http_password_resolver = http_password_resolver
|
|
144
|
+
self._resolved_http_password: str | None | _Unset = _UNSET
|
|
145
|
+
# A self-built AsyncHttpClient is the ONLY backend that holds a
|
|
146
|
+
# persistent connection worth closing (SNMP/NSDP clients are built
|
|
147
|
+
# fresh per call and need no equivalent teardown). Tracked separately
|
|
148
|
+
# from `_http_client` so `aclose()` only ever tears down a client THIS
|
|
149
|
+
# facade built -- never one the caller injected and therefore owns.
|
|
150
|
+
self._built_http_client: AsyncHttpClient | None = None
|
|
151
|
+
self._reader_cache: dict[
|
|
152
|
+
Backend, AsyncSnmpReader | AsyncNsdpReader | AsyncHttpReader
|
|
153
|
+
] = {}
|
|
154
|
+
self._writer_cache: dict[
|
|
155
|
+
Backend, AsyncSnmpWriter | AsyncNsdpWriter | AsyncHttpWriter
|
|
156
|
+
] = {}
|
|
157
|
+
self.protected_ports = protected_ports
|
|
158
|
+
|
|
159
|
+
async def __aenter__(self) -> Self:
|
|
160
|
+
return self
|
|
161
|
+
|
|
162
|
+
async def __aexit__(
|
|
163
|
+
self,
|
|
164
|
+
exc_type: type[BaseException] | None,
|
|
165
|
+
exc: BaseException | None,
|
|
166
|
+
tb: TracebackType | None,
|
|
167
|
+
) -> None:
|
|
168
|
+
await self.aclose()
|
|
169
|
+
|
|
170
|
+
async def aclose(self) -> None:
|
|
171
|
+
"""Release the HTTP client THIS facade built (never one injected by
|
|
172
|
+
the caller). Safe to call even when no HTTP op was ever dispatched."""
|
|
173
|
+
if self._built_http_client is not None:
|
|
174
|
+
await self._built_http_client.aclose()
|
|
175
|
+
self._built_http_client = None
|
|
176
|
+
|
|
177
|
+
@classmethod
|
|
178
|
+
def from_config(
|
|
179
|
+
cls, cfg: SwitchConfig, *, env: Mapping[str, str] | None = None
|
|
180
|
+
) -> AsyncSwitch:
|
|
181
|
+
# Resolve the SNMP write community LAZILY (on first write), never here
|
|
182
|
+
# (mirrors SyncSwitch.from_config -- review item 4).
|
|
183
|
+
_env = env if env is not None else os.environ
|
|
184
|
+
|
|
185
|
+
def _resolve_write_community() -> str | None:
|
|
186
|
+
return cfg.snmp_write_community(env=_env)
|
|
187
|
+
|
|
188
|
+
def _resolve_nsdp_password() -> str | None:
|
|
189
|
+
# Plus switches share ONE web-admin password across HTTP + NSDP, so
|
|
190
|
+
# reusing the http_password spec as the NSDP v1 auth password is
|
|
191
|
+
# intentional and correct. A dedicated ``nsdp.password`` config key is
|
|
192
|
+
# a trivial future follow-up (the facade already accepts a distinct
|
|
193
|
+
# nsdp_password/nsdp_password_resolver) if a deployment ever needs to
|
|
194
|
+
# split them; do NOT add a separate key now.
|
|
195
|
+
return cfg.http_password(env=_env)
|
|
196
|
+
|
|
197
|
+
def _resolve_http_password() -> str | None:
|
|
198
|
+
return cfg.http_password(env=_env)
|
|
199
|
+
|
|
200
|
+
return cls(
|
|
201
|
+
cfg.model, cfg.host,
|
|
202
|
+
snmp_community=cfg.snmp_community,
|
|
203
|
+
snmp_write_community_resolver=_resolve_write_community,
|
|
204
|
+
nsdp_interface=cfg.nsdp_interface,
|
|
205
|
+
nsdp_password_resolver=_resolve_nsdp_password,
|
|
206
|
+
http_password_resolver=_resolve_http_password,
|
|
207
|
+
protected_ports=cfg.protected_ports,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
def _http_session(self) -> AsyncHttpSession:
|
|
211
|
+
if self._http_client is not None:
|
|
212
|
+
return self._http_client
|
|
213
|
+
if self._built_http_client is None:
|
|
214
|
+
self._built_http_client = build_async_http_client(
|
|
215
|
+
self.host, self._resolve_http_password(), self.model
|
|
216
|
+
)
|
|
217
|
+
return self._built_http_client
|
|
218
|
+
|
|
219
|
+
def _resolve_http_password(self) -> str | None:
|
|
220
|
+
if not isinstance(self._resolved_http_password, _Unset):
|
|
221
|
+
return self._resolved_http_password
|
|
222
|
+
resolved: str | None
|
|
223
|
+
if self._http_password is not None:
|
|
224
|
+
resolved = self._http_password
|
|
225
|
+
elif self._http_password_resolver is not None:
|
|
226
|
+
resolved = self._http_password_resolver()
|
|
227
|
+
else:
|
|
228
|
+
resolved = None
|
|
229
|
+
self._resolved_http_password = resolved
|
|
230
|
+
return resolved
|
|
231
|
+
|
|
232
|
+
def _reader_for(
|
|
233
|
+
self, backend: Backend
|
|
234
|
+
) -> AsyncSnmpReader | AsyncNsdpReader | AsyncHttpReader:
|
|
235
|
+
cached = self._reader_cache.get(backend)
|
|
236
|
+
if cached is not None:
|
|
237
|
+
return cached
|
|
238
|
+
reader: AsyncSnmpReader | AsyncNsdpReader | AsyncHttpReader
|
|
239
|
+
if backend is Backend.SNMP:
|
|
240
|
+
client = self._snmp_client
|
|
241
|
+
if client is None:
|
|
242
|
+
client = build_async_snmp_client(self.host, self._snmp_community)
|
|
243
|
+
reader = AsyncSnmpReader(client, self.model)
|
|
244
|
+
elif backend is Backend.NSDP:
|
|
245
|
+
nsdp = self._nsdp_client
|
|
246
|
+
if nsdp is None:
|
|
247
|
+
nsdp = build_async_nsdp_client(self.host, self._nsdp_interface)
|
|
248
|
+
reader = AsyncNsdpReader(nsdp, self.model)
|
|
249
|
+
else: # Backend.HTTP
|
|
250
|
+
if not http_reads_supported(self.model):
|
|
251
|
+
raise UnsupportedCapabilityError(
|
|
252
|
+
f"model {self.model.key!r} HTTP reads are "
|
|
253
|
+
"UNVERIFIED-pending-capture"
|
|
254
|
+
)
|
|
255
|
+
reader = AsyncHttpReader(
|
|
256
|
+
_LazyAsyncHttpSession(self._http_session), self.model
|
|
257
|
+
)
|
|
258
|
+
self._reader_cache[backend] = reader
|
|
259
|
+
return reader
|
|
260
|
+
|
|
261
|
+
def _writer_for(
|
|
262
|
+
self, backend: Backend
|
|
263
|
+
) -> AsyncSnmpWriter | AsyncNsdpWriter | AsyncHttpWriter:
|
|
264
|
+
cached = self._writer_cache.get(backend)
|
|
265
|
+
if cached is not None:
|
|
266
|
+
return cached
|
|
267
|
+
writer: AsyncSnmpWriter | AsyncNsdpWriter | AsyncHttpWriter
|
|
268
|
+
if backend is Backend.SNMP:
|
|
269
|
+
client = self._snmp_write_client
|
|
270
|
+
if client is None:
|
|
271
|
+
client = build_async_snmp_write_client(
|
|
272
|
+
self.host, self._resolve_write_community()
|
|
273
|
+
)
|
|
274
|
+
writer = AsyncSnmpWriter(
|
|
275
|
+
client, self.model, protected_ports=self.protected_ports
|
|
276
|
+
)
|
|
277
|
+
elif backend is Backend.NSDP:
|
|
278
|
+
nsdp = self._nsdp_write_client
|
|
279
|
+
if nsdp is None:
|
|
280
|
+
nsdp = build_async_nsdp_client(self.host, self._nsdp_interface)
|
|
281
|
+
password = self._resolve_nsdp_password()
|
|
282
|
+
if password is None:
|
|
283
|
+
raise CredentialError(
|
|
284
|
+
f"no NSDP admin password configured for {self.host!r}"
|
|
285
|
+
)
|
|
286
|
+
writer = AsyncNsdpWriter(
|
|
287
|
+
nsdp, self.model, password=password,
|
|
288
|
+
protected_ports=self.protected_ports,
|
|
289
|
+
)
|
|
290
|
+
else: # Backend.HTTP
|
|
291
|
+
if not http_reads_supported(self.model):
|
|
292
|
+
raise UnsupportedCapabilityError(
|
|
293
|
+
f"model {self.model.key!r} HTTP writes are "
|
|
294
|
+
"UNVERIFIED-pending-capture"
|
|
295
|
+
)
|
|
296
|
+
writer = AsyncHttpWriter(
|
|
297
|
+
_LazyAsyncHttpSession(self._http_session), self.model,
|
|
298
|
+
protected_ports=self.protected_ports,
|
|
299
|
+
)
|
|
300
|
+
self._writer_cache[backend] = writer
|
|
301
|
+
return writer
|
|
302
|
+
|
|
303
|
+
async def _read(
|
|
304
|
+
self,
|
|
305
|
+
op: Callable[
|
|
306
|
+
[AsyncSnmpReader | AsyncNsdpReader | AsyncHttpReader], Awaitable[_R]
|
|
307
|
+
],
|
|
308
|
+
) -> _R:
|
|
309
|
+
last: UnsupportedCapabilityError | None = None
|
|
310
|
+
for backend in _BACKEND_PREFERENCE:
|
|
311
|
+
if backend not in self.model.backends:
|
|
312
|
+
continue
|
|
313
|
+
try:
|
|
314
|
+
reader = self._reader_for(backend)
|
|
315
|
+
except UnsupportedCapabilityError as exc:
|
|
316
|
+
last = exc
|
|
317
|
+
continue
|
|
318
|
+
try:
|
|
319
|
+
return await op(reader)
|
|
320
|
+
except UnsupportedCapabilityError as exc:
|
|
321
|
+
last = exc
|
|
322
|
+
if last is not None:
|
|
323
|
+
raise last
|
|
324
|
+
raise UnsupportedCapabilityError(
|
|
325
|
+
f"model {self.model.key!r} has no backend supporting this operation"
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
async def _write(
|
|
329
|
+
self,
|
|
330
|
+
op: Callable[
|
|
331
|
+
[AsyncSnmpWriter | AsyncNsdpWriter | AsyncHttpWriter], Awaitable[None]
|
|
332
|
+
],
|
|
333
|
+
) -> None:
|
|
334
|
+
last: UnsupportedCapabilityError | None = None
|
|
335
|
+
for backend in _BACKEND_PREFERENCE:
|
|
336
|
+
if backend not in self.model.backends:
|
|
337
|
+
continue
|
|
338
|
+
try:
|
|
339
|
+
writer = self._writer_for(backend)
|
|
340
|
+
except UnsupportedCapabilityError as exc:
|
|
341
|
+
last = exc
|
|
342
|
+
continue
|
|
343
|
+
try:
|
|
344
|
+
await op(writer)
|
|
345
|
+
return
|
|
346
|
+
except UnsupportedCapabilityError as exc:
|
|
347
|
+
last = exc
|
|
348
|
+
if last is not None:
|
|
349
|
+
raise last
|
|
350
|
+
raise UnsupportedCapabilityError(
|
|
351
|
+
f"model {self.model.key!r} has no backend supporting this operation"
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
async def get_ports(self) -> list[PortStatus]:
|
|
355
|
+
return await self._read(lambda r: r.get_ports())
|
|
356
|
+
|
|
357
|
+
async def get_stats(self) -> list[PortStats]:
|
|
358
|
+
return await self._read(lambda r: r.get_stats())
|
|
359
|
+
|
|
360
|
+
async def get_vlans(self) -> list[VLANInfo]:
|
|
361
|
+
return await self._read(lambda r: r.get_vlans())
|
|
362
|
+
|
|
363
|
+
async def get_pvids(self) -> list[tuple[int, int]]:
|
|
364
|
+
return await self._read(lambda r: r.get_pvids())
|
|
365
|
+
|
|
366
|
+
async def get_lldp(self) -> list[LLDPNeighbor]:
|
|
367
|
+
return await self._read(lambda r: r.get_lldp())
|
|
368
|
+
|
|
369
|
+
async def get_macs(self) -> list[MacEntry]:
|
|
370
|
+
require_mac_table(self.model)
|
|
371
|
+
return await self._read(lambda r: r.get_macs())
|
|
372
|
+
|
|
373
|
+
async def get_poe(self) -> list[PoEStatus]:
|
|
374
|
+
return await self._read(lambda r: r.get_poe())
|
|
375
|
+
|
|
376
|
+
async def get_sensors(self) -> list[Sensor]:
|
|
377
|
+
return await self._read(lambda r: r.get_sensors())
|
|
378
|
+
|
|
379
|
+
async def get_mgmt_ip(self) -> MgmtIpConfig:
|
|
380
|
+
return await self._read(lambda r: r.get_mgmt_ip())
|
|
381
|
+
|
|
382
|
+
async def nsdp_device(self) -> NsdpDevice:
|
|
383
|
+
"""Async twin of ``SyncSwitch.nsdp_device`` -- see there."""
|
|
384
|
+
reader = self._reader_for(Backend.NSDP)
|
|
385
|
+
assert isinstance(reader, AsyncNsdpReader)
|
|
386
|
+
return await reader.get_device()
|
|
387
|
+
|
|
388
|
+
async def identify(self) -> DetectedModel:
|
|
389
|
+
"""Async twin of ``SyncSwitch.identify`` -- see there."""
|
|
390
|
+
client = self._snmp_client
|
|
391
|
+
if client is None:
|
|
392
|
+
client = build_async_snmp_client(self.host, self._snmp_community)
|
|
393
|
+
return await async_read_system_info(client)
|
|
394
|
+
|
|
395
|
+
async def snapshot(self) -> SwitchData:
|
|
396
|
+
"""Aggregate every read op, routing each field to the first backend
|
|
397
|
+
that supports it (SNMP > NSDP > HTTP). A field NO backend can serve
|
|
398
|
+
degrades to ()/None; a field a backend DOES serve stays populated
|
|
399
|
+
(gs305ep: ports/stats/vlans/pvids/mgmt via NSDP, poe via HTTP)."""
|
|
400
|
+
|
|
401
|
+
async def _opt(
|
|
402
|
+
op: Callable[
|
|
403
|
+
[AsyncSnmpReader | AsyncNsdpReader | AsyncHttpReader],
|
|
404
|
+
Awaitable[list[Any]],
|
|
405
|
+
],
|
|
406
|
+
) -> tuple[Any, ...]:
|
|
407
|
+
try:
|
|
408
|
+
return tuple(await self._read(op))
|
|
409
|
+
except UnsupportedCapabilityError:
|
|
410
|
+
return ()
|
|
411
|
+
|
|
412
|
+
try:
|
|
413
|
+
mgmt: MgmtIpConfig | None = await self._read(lambda r: r.get_mgmt_ip())
|
|
414
|
+
except UnsupportedCapabilityError:
|
|
415
|
+
mgmt = None
|
|
416
|
+
|
|
417
|
+
return SwitchData(
|
|
418
|
+
model=self.model.key,
|
|
419
|
+
host=self.host,
|
|
420
|
+
ports=await _opt(lambda r: r.get_ports()),
|
|
421
|
+
stats=await _opt(lambda r: r.get_stats()),
|
|
422
|
+
vlans=await _opt(lambda r: r.get_vlans()),
|
|
423
|
+
pvids=await _opt(lambda r: r.get_pvids()),
|
|
424
|
+
mgmt_ip=mgmt,
|
|
425
|
+
poe=await _opt(lambda r: r.get_poe()),
|
|
426
|
+
lldp=await _opt(lambda r: r.get_lldp()),
|
|
427
|
+
sensors=await _opt(lambda r: r.get_sensors()),
|
|
428
|
+
macs=await _opt(lambda r: r.get_macs()),
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
def _resolve_write_community(self) -> str | None:
|
|
432
|
+
# Resolved once on first write, then cached: an explicit community
|
|
433
|
+
# wins, else the stashed from_config resolver runs now (may raise),
|
|
434
|
+
# else None. Every subsequent write reuses the cached result instead
|
|
435
|
+
# of re-invoking the resolver (e.g. a ``!command`` spec must not
|
|
436
|
+
# re-exec its subprocess on every single write).
|
|
437
|
+
if not isinstance(self._resolved_write_community, _Unset):
|
|
438
|
+
return self._resolved_write_community
|
|
439
|
+
resolved: str | None
|
|
440
|
+
if self._snmp_write_community is not None:
|
|
441
|
+
resolved = self._snmp_write_community
|
|
442
|
+
elif self._snmp_write_community_resolver is not None:
|
|
443
|
+
resolved = self._snmp_write_community_resolver()
|
|
444
|
+
else:
|
|
445
|
+
resolved = None
|
|
446
|
+
self._resolved_write_community = resolved
|
|
447
|
+
return resolved
|
|
448
|
+
|
|
449
|
+
def _resolve_nsdp_password(self) -> str | None:
|
|
450
|
+
if not isinstance(self._resolved_nsdp_password, _Unset):
|
|
451
|
+
return self._resolved_nsdp_password
|
|
452
|
+
resolved: str | None
|
|
453
|
+
if self._nsdp_password is not None:
|
|
454
|
+
resolved = self._nsdp_password
|
|
455
|
+
elif self._nsdp_password_resolver is not None:
|
|
456
|
+
resolved = self._nsdp_password_resolver()
|
|
457
|
+
else:
|
|
458
|
+
resolved = None
|
|
459
|
+
self._resolved_nsdp_password = resolved
|
|
460
|
+
return resolved
|
|
461
|
+
|
|
462
|
+
async def set_poe(self, port: int, on: bool, *, force: bool = False) -> None:
|
|
463
|
+
await self._write(lambda w: w.set_poe(port, on, force=force))
|
|
464
|
+
|
|
465
|
+
async def set_port_enabled(
|
|
466
|
+
self, port: int, enabled: bool, *, force: bool = False
|
|
467
|
+
) -> None:
|
|
468
|
+
await self._write(lambda w: w.set_port_enabled(port, enabled, force=force))
|
|
469
|
+
|
|
470
|
+
async def set_pvid(self, port: int, vlan: int, *, force: bool = False) -> None:
|
|
471
|
+
await self._write(lambda w: w.set_pvid(port, vlan, force=force))
|
|
472
|
+
|
|
473
|
+
async def set_vlan_membership(
|
|
474
|
+
self, vlan: int, port: int, mode: VlanMode, *, force: bool = False
|
|
475
|
+
) -> None:
|
|
476
|
+
await self._write(
|
|
477
|
+
lambda w: w.set_vlan_membership(vlan, port, mode, force=force)
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
async def create_vlan(self, vlan: int, name: str, *, force: bool = False) -> None:
|
|
481
|
+
await self._write(lambda w: w.create_vlan(vlan, name, force=force))
|
|
482
|
+
|
|
483
|
+
async def delete_vlan(self, vlan: int, *, force: bool = False) -> None:
|
|
484
|
+
# SAFETY RAIL: mirrors SyncSwitch.delete_vlan (see its docstring).
|
|
485
|
+
# HttpWriter.delete_vlan does NOT itself guard protected member ports,
|
|
486
|
+
# and NsdpWriter.delete_vlan ALWAYS raises UnsupportedCapabilityError
|
|
487
|
+
# (NSDP has no VLAN lifecycle ops), so any {NSDP, HTTP} model falls
|
|
488
|
+
# straight through to HTTP -- guard here so every backend gets the
|
|
489
|
+
# same protected-port safety rail.
|
|
490
|
+
await self._guard_vlan_delete_members(vlan, force=force)
|
|
491
|
+
await self._write(lambda w: w.delete_vlan(vlan, force=force))
|
|
492
|
+
|
|
493
|
+
async def _guard_vlan_delete_members(self, vlan: int, *, force: bool) -> None:
|
|
494
|
+
if force:
|
|
495
|
+
return
|
|
496
|
+
try:
|
|
497
|
+
vlans = await self._read(lambda r: r.get_vlans())
|
|
498
|
+
except UnsupportedCapabilityError:
|
|
499
|
+
return
|
|
500
|
+
for v in vlans:
|
|
501
|
+
if v.vlan_id == vlan:
|
|
502
|
+
clash = v.member_ports & self.protected_ports
|
|
503
|
+
if clash:
|
|
504
|
+
raise ProtectedPortError(
|
|
505
|
+
f"VLAN {vlan} includes protected port(s) {sorted(clash)}; "
|
|
506
|
+
f"pass force=True to delete it anyway"
|
|
507
|
+
)
|
|
508
|
+
return
|
|
509
|
+
|
|
510
|
+
async def cycle_poe(
|
|
511
|
+
self, port: int, *, force: bool = False,
|
|
512
|
+
timeouts: PoeCycleTimeouts = _DEFAULT_POE_TIMEOUTS,
|
|
513
|
+
) -> None:
|
|
514
|
+
await self._write(lambda w: w.cycle_poe(port, force=force, timeouts=timeouts))
|
|
515
|
+
|
|
516
|
+
async def clear_poe_fault(
|
|
517
|
+
self, port: int, *, force: bool = False,
|
|
518
|
+
timeouts: PoeCycleTimeouts = _DEFAULT_POE_TIMEOUTS,
|
|
519
|
+
) -> None:
|
|
520
|
+
await self._write(
|
|
521
|
+
lambda w: w.clear_poe_fault(port, force=force, timeouts=timeouts)
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
async def set_mgmt_ip(
|
|
525
|
+
self, address: str, netmask: str, gateway: str, *, force: bool = False
|
|
526
|
+
) -> None:
|
|
527
|
+
await self._write(
|
|
528
|
+
lambda w: w.set_mgmt_ip(address, netmask, gateway, force=force)
|
|
529
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""The ``ngsw`` command-line interface (a thin layer over SyncSwitch)."""
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""``ngsw capture``: record a real switch's state + protocol exchanges.
|
|
2
|
+
|
|
3
|
+
Opt-in, live-switch, never run in CI. The state snapshot uses the public
|
|
4
|
+
``SyncSwitch.snapshot()`` (works against any backend); the reference raw walk
|
|
5
|
+
(``snmpbulkwalk`` output) requires live-switch access and is only recorded when
|
|
6
|
+
a ``raw_walk`` callable is supplied. Output is a JSON file used *for reference*
|
|
7
|
+
when hand-authoring fixtures (design spec Sec7.1) -- never committed as-is.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import subprocess
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from datetime import UTC, datetime
|
|
15
|
+
from typing import TYPE_CHECKING
|
|
16
|
+
|
|
17
|
+
from netgear_switch.errors import ConfigError
|
|
18
|
+
|
|
19
|
+
from . import format as fmt
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from collections.abc import Callable, Sequence
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from netgear_switch.config import Runner
|
|
26
|
+
from netgear_switch.sync_api import SyncSwitch
|
|
27
|
+
|
|
28
|
+
_WALK_TIMEOUT = 30
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class CaptureRecord:
|
|
33
|
+
model: str
|
|
34
|
+
host: str
|
|
35
|
+
captured_at: str
|
|
36
|
+
snapshot: object
|
|
37
|
+
raw_exchanges: list[dict[str, object]] = field(default_factory=list)
|
|
38
|
+
notes: list[str] = field(default_factory=list)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def default_raw_walk(
|
|
42
|
+
host: str,
|
|
43
|
+
base: str,
|
|
44
|
+
*,
|
|
45
|
+
community: str = "public",
|
|
46
|
+
runner: Runner = subprocess.run,
|
|
47
|
+
) -> list[str]:
|
|
48
|
+
"""Shell out to net-snmp's snmpbulkwalk (live hardware) for a reference walk.
|
|
49
|
+
|
|
50
|
+
Never uses ``shell=True`` or string interpolation -- the argv is a fixed
|
|
51
|
+
list, so there is no shell-injection surface even though ``host``/``base``
|
|
52
|
+
are caller-controlled. A missing binary or a timeout propagates as the
|
|
53
|
+
normal ``subprocess`` exception (``FileNotFoundError`` /
|
|
54
|
+
``subprocess.TimeoutExpired``); a nonzero exit is turned into a
|
|
55
|
+
``RuntimeError`` carrying the process's stderr. ``run_capture`` is
|
|
56
|
+
responsible for catching these and recording an honest failure instead of
|
|
57
|
+
crashing the whole capture.
|
|
58
|
+
"""
|
|
59
|
+
result = runner(
|
|
60
|
+
["snmpbulkwalk", "-v2c", "-c", community, host, base],
|
|
61
|
+
capture_output=True,
|
|
62
|
+
text=True,
|
|
63
|
+
timeout=_WALK_TIMEOUT,
|
|
64
|
+
)
|
|
65
|
+
if result.returncode != 0:
|
|
66
|
+
raise RuntimeError(
|
|
67
|
+
f"snmpbulkwalk exited {result.returncode}: {result.stderr.strip()}"
|
|
68
|
+
)
|
|
69
|
+
return result.stdout.splitlines()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def run_capture(
|
|
73
|
+
switch: SyncSwitch,
|
|
74
|
+
out_path: Path,
|
|
75
|
+
*,
|
|
76
|
+
snapshot_only: bool = False,
|
|
77
|
+
raw_walk: Callable[[str, str], Sequence[str]] | None = None,
|
|
78
|
+
now: Callable[[], datetime] | None = None,
|
|
79
|
+
) -> CaptureRecord:
|
|
80
|
+
clock = now or (lambda: datetime.now(UTC))
|
|
81
|
+
record = CaptureRecord(
|
|
82
|
+
model=switch.model.key,
|
|
83
|
+
host=switch.host,
|
|
84
|
+
captured_at=clock().isoformat(),
|
|
85
|
+
snapshot=fmt.jsonify(switch.snapshot()),
|
|
86
|
+
)
|
|
87
|
+
if snapshot_only:
|
|
88
|
+
record.notes.append("snapshot-only: no raw protocol exchange recorded")
|
|
89
|
+
elif raw_walk is None:
|
|
90
|
+
record.notes.append(
|
|
91
|
+
"no raw-capture backend available; recording a raw protocol exchange "
|
|
92
|
+
"needs live-switch access (SNMP walk / NSDP / HTTP). Re-run on hardware."
|
|
93
|
+
)
|
|
94
|
+
else:
|
|
95
|
+
base = switch.model.snmp_vendor_base or "1.3.6.1.2.1"
|
|
96
|
+
request = f"walk {base}"
|
|
97
|
+
try:
|
|
98
|
+
lines = list(raw_walk(switch.host, base))
|
|
99
|
+
except Exception as exc:
|
|
100
|
+
# A raw walk can fail in many ways on real hardware (missing
|
|
101
|
+
# snmpbulkwalk binary, nonzero exit, network timeout). The walk is
|
|
102
|
+
# optional and best-effort: record the honest failure so the
|
|
103
|
+
# capture record isn't silently missing data, but never let it
|
|
104
|
+
# abort the rest of the capture.
|
|
105
|
+
error = f"{type(exc).__name__}: {exc}"
|
|
106
|
+
record.raw_exchanges.append(
|
|
107
|
+
{"protocol": "snmp", "request": request, "error": error}
|
|
108
|
+
)
|
|
109
|
+
record.notes.append(f"raw protocol walk failed: {error}")
|
|
110
|
+
else:
|
|
111
|
+
record.raw_exchanges.append(
|
|
112
|
+
{"protocol": "snmp", "request": request, "response": lines}
|
|
113
|
+
)
|
|
114
|
+
try:
|
|
115
|
+
out_path.write_text(json.dumps(_as_dict(record), indent=2))
|
|
116
|
+
except OSError as exc:
|
|
117
|
+
raise ConfigError(
|
|
118
|
+
f"cannot write capture output to {out_path}: {exc}"
|
|
119
|
+
) from exc
|
|
120
|
+
return record
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _as_dict(record: CaptureRecord) -> dict[str, object]:
|
|
124
|
+
return {
|
|
125
|
+
"model": record.model,
|
|
126
|
+
"host": record.host,
|
|
127
|
+
"captured_at": record.captured_at,
|
|
128
|
+
"snapshot": record.snapshot,
|
|
129
|
+
"raw_exchanges": record.raw_exchanges,
|
|
130
|
+
"notes": record.notes,
|
|
131
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Shared CLI context and exit-code policy (leaf module, no cli/ imports)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from netgear_switch.errors import ProtectedPortError, WriteVerificationError
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from typing import TextIO
|
|
12
|
+
|
|
13
|
+
from netgear_switch.errors import NetgearSwitchError
|
|
14
|
+
|
|
15
|
+
EXIT_OK = 0
|
|
16
|
+
EXIT_ERROR = 1
|
|
17
|
+
EXIT_USAGE = 2
|
|
18
|
+
EXIT_VERIFY = 3
|
|
19
|
+
EXIT_PROTECTED = 4
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class CliContext:
|
|
24
|
+
"""Streams and global flags threaded through every command handler."""
|
|
25
|
+
|
|
26
|
+
out: TextIO
|
|
27
|
+
err: TextIO
|
|
28
|
+
inp: TextIO
|
|
29
|
+
as_json: bool
|
|
30
|
+
verbose: bool
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def exit_code_for(exc: NetgearSwitchError) -> int:
|
|
34
|
+
"""Map a library error to a distinct process exit code."""
|
|
35
|
+
if isinstance(exc, WriteVerificationError):
|
|
36
|
+
return EXIT_VERIFY
|
|
37
|
+
if isinstance(exc, ProtectedPortError):
|
|
38
|
+
return EXIT_PROTECTED
|
|
39
|
+
return EXIT_ERROR
|