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.
Files changed (66) hide show
  1. netgear_switch/__init__.py +132 -0
  2. netgear_switch/_dispatch.py +178 -0
  3. netgear_switch/_version.py +24 -0
  4. netgear_switch/aio_api.py +529 -0
  5. netgear_switch/cli/__init__.py +1 -0
  6. netgear_switch/cli/capture.py +131 -0
  7. netgear_switch/cli/context.py +39 -0
  8. netgear_switch/cli/format.py +201 -0
  9. netgear_switch/cli/main.py +484 -0
  10. netgear_switch/cli/resolve.py +108 -0
  11. netgear_switch/cli/safety.py +71 -0
  12. netgear_switch/config.py +184 -0
  13. netgear_switch/errors.py +52 -0
  14. netgear_switch/http_read.py +174 -0
  15. netgear_switch/http_write.py +420 -0
  16. netgear_switch/models.py +156 -0
  17. netgear_switch/nsdp_read.py +221 -0
  18. netgear_switch/nsdp_write.py +315 -0
  19. netgear_switch/protocols/__init__.py +1 -0
  20. netgear_switch/protocols/http/__init__.py +1 -0
  21. netgear_switch/protocols/http/crypt.py +29 -0
  22. netgear_switch/protocols/http/endpoints.py +165 -0
  23. netgear_switch/protocols/http/forms.py +77 -0
  24. netgear_switch/protocols/http/parse.py +238 -0
  25. netgear_switch/protocols/http/session.py +29 -0
  26. netgear_switch/protocols/nsdp/__init__.py +7 -0
  27. netgear_switch/protocols/nsdp/auth.py +33 -0
  28. netgear_switch/protocols/nsdp/client.py +67 -0
  29. netgear_switch/protocols/nsdp/parsers.py +209 -0
  30. netgear_switch/protocols/nsdp/protocol.py +201 -0
  31. netgear_switch/protocols/nsdp/types.py +137 -0
  32. netgear_switch/protocols/nsdp/write.py +98 -0
  33. netgear_switch/protocols/snmp/__init__.py +1 -0
  34. netgear_switch/protocols/snmp/client.py +88 -0
  35. netgear_switch/protocols/snmp/oids.py +125 -0
  36. netgear_switch/protocols/snmp/parse.py +777 -0
  37. netgear_switch/protocols/snmp/write.py +112 -0
  38. netgear_switch/py.typed +0 -0
  39. netgear_switch/registry.py +227 -0
  40. netgear_switch/snmp_read.py +226 -0
  41. netgear_switch/snmp_write.py +625 -0
  42. netgear_switch/sync_api.py +557 -0
  43. netgear_switch/transport/__init__.py +1 -0
  44. netgear_switch/transport/aio/__init__.py +1 -0
  45. netgear_switch/transport/aio/nsdp_udp.py +152 -0
  46. netgear_switch/transport/aio/snmp_pysnmp.py +247 -0
  47. netgear_switch/transport/http/__init__.py +1 -0
  48. netgear_switch/transport/http/client.py +217 -0
  49. netgear_switch/transport/sync/__init__.py +1 -0
  50. netgear_switch/transport/sync/nsdp_udp.py +109 -0
  51. netgear_switch/transport/sync/snmp_netsnmp_cli.py +257 -0
  52. netgear_switch/virtual/__init__.py +8 -0
  53. netgear_switch/virtual/faces/__init__.py +2 -0
  54. netgear_switch/virtual/faces/http.py +164 -0
  55. netgear_switch/virtual/faces/mibview.py +92 -0
  56. netgear_switch/virtual/faces/nsdp.py +124 -0
  57. netgear_switch/virtual/faces/snmp.py +412 -0
  58. netgear_switch/virtual/seed.py +220 -0
  59. netgear_switch/virtual/server.py +106 -0
  60. netgear_switch/virtual/state.py +615 -0
  61. netgear_switch/virtual/web.py +210 -0
  62. python_netgear_switch_library-0.0.post154.dist-info/METADATA +85 -0
  63. python_netgear_switch_library-0.0.post154.dist-info/RECORD +66 -0
  64. python_netgear_switch_library-0.0.post154.dist-info/WHEEL +4 -0
  65. python_netgear_switch_library-0.0.post154.dist-info/entry_points.txt +2 -0
  66. python_netgear_switch_library-0.0.post154.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,165 @@
1
+ """Per-model web-UI endpoint/CGI definitions (pure data).
2
+
3
+ Each ``HttpModelSpec`` records how one model logs in and which page each read
4
+ op scrapes. ``scheme_verified``/``reads_verified`` mark whether that model's
5
+ flows are grounded in captured prior art or still
6
+ ``UNVERIFIED-pending-capture``:
7
+
8
+ - ``gs305ep`` (Plus PoE): login, dashboard/stats, PoE, VLAN/PVID, and reboot
9
+ endpoints are GROUNDED in ``py_netgear_plus/models.py`` (GS30xSeries /
10
+ GS30xEPxSeries: ``CRYPT_FUNCTION="merge_hash"``, ``LOGIN_TEMPLATE``,
11
+ PoE/VLAN CGI paths) and in ``rcfiles/bin/netgear-smp-vlan`` (identical
12
+ ``merge`` hash scheme observed on GS105PE; ``8021qCf.cgi`` /
13
+ ``8021qMembe.cgi`` / ``portPVID.cgi`` field shapes and wire codes). Both
14
+ ``scheme_verified`` and ``reads_verified`` are ``True``.
15
+ - ``gsm7228ps`` (Smart Managed Pro / S3300): the plaintext cheetah login form
16
+ is GROUNDED in ``certbot-hook-netgear-switches/netgear-updater.py``
17
+ (``S3300Updater``), so ``scheme_verified`` is ``True``. SNMP is the
18
+ preferred read/write path for this model; no web-UI read/write flow has
19
+ been captured, so ``reads_verified`` is ``False`` and the reader/writer
20
+ refuse rather than fabricate.
21
+ - ``gs110emx`` (Plus EMx / Gambit): the login hash function and Gambit-token
22
+ extraction are NOT grounded in a captured session (only partially
23
+ corroborated by ``py_netgear_plus/models.py`` EMxSeries/GS110EMX). Both
24
+ ``scheme_verified`` and ``reads_verified`` are ``False``.
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import enum
29
+ from dataclasses import dataclass
30
+ from types import MappingProxyType
31
+ from typing import TYPE_CHECKING
32
+
33
+ from ...errors import UnsupportedCapabilityError
34
+ from ...registry import Backend
35
+
36
+ if TYPE_CHECKING:
37
+ from collections.abc import Mapping
38
+
39
+ from ...registry import SwitchModel
40
+
41
+
42
+ class LoginScheme(enum.Enum):
43
+ MERGE_HASH_CGI = "merge_hash_cgi" # Plus SID scheme (gs305ep) — GROUNDED
44
+ GAMBIT = "gambit" # EMx scheme (gs110emx) — UNVERIFIED
45
+ CHEETAH_FORM = "cheetah_form" # Pro/S3300 (gsm7228ps) — plaintext form
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class HttpModelSpec:
50
+ model_key: str
51
+ scheme: LoginScheme
52
+ scheme_verified: bool
53
+ login_path: str
54
+ password_field: str
55
+ cookie_name: str
56
+ needs_rand: bool
57
+ dashboard_path: str | None
58
+ stats_path: str | None
59
+ poe_config_path: str | None
60
+ poe_status_path: str | None
61
+ vlan_config_path: str | None
62
+ vlan_membership_path: str | None
63
+ pvid_path: str | None
64
+ reboot_path: str | None
65
+ logout_path: str | None
66
+ is_epx_poe: bool
67
+ reads_verified: bool
68
+
69
+
70
+ # GROUNDED: py_netgear_plus/models.py GS30xSeries/GS30xEPxSeries
71
+ # (CRYPT_FUNCTION="merge_hash", ALLOWED_COOKIE_TYPES=["SID"],
72
+ # check_login_form_rand=True, LOGIN_TEMPLATE url=/login.cgi
73
+ # params={password:_password_hash}; PoE/VLAN CGI paths) plus
74
+ # rcfiles/bin/netgear-smp-vlan (identical merge-hash login observed on
75
+ # GS105PE; 8021qCf.cgi/8021qMembe.cgi/portPVID.cgi field shapes and the
76
+ # 1=Untagged/2=Tagged/3=Excluded membership wire codes). Fully grounded.
77
+ _GS305EP = HttpModelSpec(
78
+ model_key="gs305ep",
79
+ scheme=LoginScheme.MERGE_HASH_CGI,
80
+ scheme_verified=True,
81
+ login_path="/login.cgi",
82
+ password_field="password",
83
+ cookie_name="SID",
84
+ needs_rand=True,
85
+ dashboard_path="/dashboard.cgi",
86
+ stats_path="/portStatistics.cgi",
87
+ poe_config_path="/PoEPortConfig.cgi",
88
+ poe_status_path="/getPoePortStatus.cgi",
89
+ vlan_config_path="/8021qCf.cgi",
90
+ vlan_membership_path="/8021qMembe.cgi",
91
+ pvid_path="/portPVID.cgi",
92
+ reboot_path="/device_reboot.cgi",
93
+ logout_path="/logout.cgi",
94
+ is_epx_poe=True,
95
+ reads_verified=True,
96
+ )
97
+
98
+ # UNVERIFIED-pending-capture: Gambit login hash + /iss/specific pages not
99
+ # grounded in a captured session. Endpoints from py_netgear_plus EMxSeries.
100
+ _GS110EMX = HttpModelSpec(
101
+ model_key="gs110emx",
102
+ scheme=LoginScheme.GAMBIT,
103
+ scheme_verified=False,
104
+ login_path="/homepage.html",
105
+ password_field="LoginPassword",
106
+ cookie_name="gambitCookie",
107
+ needs_rand=True,
108
+ dashboard_path="/iss/specific/sysInfo.html",
109
+ stats_path="/iss/specific/interface_stats.html",
110
+ poe_config_path=None,
111
+ poe_status_path=None,
112
+ vlan_config_path=None,
113
+ vlan_membership_path=None,
114
+ pvid_path=None,
115
+ reboot_path=None,
116
+ logout_path="/iss/specific/logout.html",
117
+ is_epx_poe=False,
118
+ reads_verified=False,
119
+ )
120
+
121
+ # Login is GROUNDED: certbot-hook-netgear-switches/netgear-updater.py
122
+ # S3300Updater posts plaintext pwd= to /base/cheetah_login.html and reads
123
+ # back a SID cookie. SNMP is the preferred read/write path for this model;
124
+ # no web-UI read/write flow has been captured, so reads/writes are
125
+ # UNVERIFIED-pending-capture and only login + reboot/logout are populated.
126
+ _GSM7228PS = HttpModelSpec(
127
+ model_key="gsm7228ps",
128
+ scheme=LoginScheme.CHEETAH_FORM,
129
+ scheme_verified=True,
130
+ login_path="/base/cheetah_login.html",
131
+ password_field="pwd",
132
+ cookie_name="SID",
133
+ needs_rand=False,
134
+ dashboard_path=None,
135
+ stats_path=None,
136
+ poe_config_path=None,
137
+ poe_status_path=None,
138
+ vlan_config_path=None,
139
+ vlan_membership_path=None,
140
+ pvid_path=None,
141
+ reboot_path=None,
142
+ logout_path=None,
143
+ is_epx_poe=False,
144
+ reads_verified=False,
145
+ )
146
+
147
+ _SPECS: dict[str, HttpModelSpec] = {
148
+ s.model_key: s for s in (_GS305EP, _GS110EMX, _GSM7228PS)
149
+ }
150
+
151
+ HTTP_SPECS: Mapping[str, HttpModelSpec] = MappingProxyType(_SPECS)
152
+
153
+
154
+ def http_spec(model: SwitchModel) -> HttpModelSpec:
155
+ """Return the web-UI spec for ``model`` or raise if it has no HTTP backend."""
156
+ if Backend.HTTP not in model.backends:
157
+ raise UnsupportedCapabilityError(
158
+ f"model {model.key!r} has no HTTP backend"
159
+ )
160
+ try:
161
+ return _SPECS[model.key]
162
+ except KeyError:
163
+ raise UnsupportedCapabilityError(
164
+ f"model {model.key!r} has an HTTP backend but no endpoint spec"
165
+ ) from None
@@ -0,0 +1,77 @@
1
+ """Pure (I/O-free) web-UI write-form encoders.
2
+
3
+ Field names/values are GROUNDED against ``py_netgear_plus`` GS30xSeries
4
+ ``get_switch_poe_port_data``/``get_power_cycle_poe_port_data`` and
5
+ ``rcfiles/bin/netgear-smp-vlan`` (8021q/PVID forms). Each op requires the
6
+ page's CSRF ``hash`` (scraped just before the POST by the writer).
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from typing import TYPE_CHECKING
11
+
12
+ from ...models import VlanMode
13
+
14
+ if TYPE_CHECKING:
15
+ from collections.abc import Mapping
16
+
17
+ _WIRE = {VlanMode.UNTAGGED: "1", VlanMode.TAGGED: "2", VlanMode.EXCLUDED: "3"}
18
+
19
+
20
+ def poe_apply_form(
21
+ *, port: int, on: bool, is_epx: bool, csrf_hash: str
22
+ ) -> dict[str, str]:
23
+ return {
24
+ "ACTION": "Apply",
25
+ "portID": str(port - 1),
26
+ "ADMIN_MODE": "1" if on else "0",
27
+ "PORT_PRIO": "0",
28
+ "POW_MOD": "3",
29
+ "POW_LIMT_TYP": "2" if is_epx else "0",
30
+ "DETEC_TYP": "2",
31
+ "DISCONNECT_TYP": "2",
32
+ "hash": csrf_hash,
33
+ }
34
+
35
+
36
+ def poe_reset_form(*, port: int, csrf_hash: str) -> dict[str, str]:
37
+ return {"ACTION": "Reset", f"port{port - 1}": "checked", "hash": csrf_hash}
38
+
39
+
40
+ def pvid_form(*, port: int, vlan: int, csrf_hash: str) -> dict[str, str]:
41
+ return {f"port{port - 1}": "checked", "pvid": str(vlan), "hash": csrf_hash}
42
+
43
+
44
+ def membership_hidden_mem(states: Mapping[int, VlanMode], port_count: int) -> str:
45
+ return "".join(
46
+ _WIRE[states.get(p, VlanMode.EXCLUDED)] for p in range(1, port_count + 1)
47
+ )
48
+
49
+
50
+ def membership_form(
51
+ *, vlan: int, hidden_mem: str, csrf_hash: str
52
+ ) -> dict[str, str]:
53
+ return {"VLAN_ID": str(vlan), "hiddenMem": hidden_mem, "hash": csrf_hash}
54
+
55
+
56
+ def vlan_add_form(*, vlan: int, csrf_hash: str) -> dict[str, str]:
57
+ return {
58
+ "ACTION": "Add",
59
+ "ADD_VLANID": str(vlan),
60
+ "status": "Enable",
61
+ "hash": csrf_hash,
62
+ }
63
+
64
+
65
+ def vlan_delete_form(
66
+ *, vlan: int, checkbox_index: int, csrf_hash: str
67
+ ) -> dict[str, str]:
68
+ return {
69
+ "ACTION": "Delete",
70
+ f"vlanck{checkbox_index}": str(vlan),
71
+ "status": "Enable",
72
+ "hash": csrf_hash,
73
+ }
74
+
75
+
76
+ def reboot_form(*, csrf_hash: str) -> dict[str, str]:
77
+ return {"hash": csrf_hash}
@@ -0,0 +1,238 @@
1
+ """Pure (I/O-free) parsers mapping web-UI HTML -> shared ``models`` types.
2
+
3
+ Regex-based (no ``lxml``/``bs4`` dependency), matching the documented HTML
4
+ shape the virtual renderer (``virtual/web.py``, Slice 6 Task 10) emits and
5
+ that the captured/synthetic fixtures under ``tests/fixtures/http/`` mirror.
6
+ Real-device column offsets are UNVERIFIED-pending-capture; confirm against
7
+ captured HTML before production use (each such fixture is headed
8
+ ``UNVERIFIED-pending-capture``).
9
+
10
+ Two different failure shapes are deliberate:
11
+
12
+ - A *token* scrape (``parse_login_rand``/``parse_csrf_hash``/
13
+ ``parse_selected_vlan``) returns ``None`` when the value is absent. The
14
+ reader (Task 4) is the one with enough context to know whether that is
15
+ fatal, and raises ``HttpAuthError``/``HttpUnexpectedPageError`` itself —
16
+ this module never guesses.
17
+ - A *table/page* parser that cannot find the structure the page is
18
+ documented to always contain (e.g. no ``portID`` rows on dashboard.cgi, no
19
+ ``hiddenMem`` on 8021qMembe.cgi) raises ``HttpUnexpectedPageError`` naming
20
+ what was expected. These pages are never legitimately empty on a real
21
+ switch (port tables always list every physical port), so a missing
22
+ structure means the wrong page came back, not "empty switch" -> never
23
+ silently swallowed into an empty list/dict.
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import re
28
+
29
+ from ...errors import HttpUnexpectedPageError
30
+ from ...models import PoEDetect, PoEStatus, PortStats, PortStatus, VlanMode
31
+
32
+ _ROW_RE = re.compile(r'<tr\s+class="portID">(.*?)</tr>', re.DOTALL | re.IGNORECASE)
33
+ _TD_RE = re.compile(r"<td[^>]*>(.*?)</td>", re.DOTALL | re.IGNORECASE)
34
+ _TAG_RE = re.compile(r"<[^>]+>")
35
+
36
+ _WIRE_TO_MODE = {"1": VlanMode.UNTAGGED, "2": VlanMode.TAGGED, "3": VlanMode.EXCLUDED}
37
+ _DETECT_TEXT = {
38
+ "delivering": PoEDetect.DELIVERING,
39
+ "searching": PoEDetect.SEARCHING,
40
+ "disabled": PoEDetect.DISABLED,
41
+ "fault": PoEDetect.FAULT,
42
+ }
43
+
44
+
45
+ def _cells(row_html: str) -> list[str]:
46
+ return [_TAG_RE.sub("", c).strip() for c in _TD_RE.findall(row_html)]
47
+
48
+
49
+ def _int(text: str) -> int | None:
50
+ m = re.search(r"-?\d+", text)
51
+ return int(m.group()) if m else None
52
+
53
+
54
+ def parse_login_rand(html: str) -> str | None:
55
+ """Scrape the login nonce from ``<input id="rand" ... value="...">``."""
56
+ m = re.search(r'id=["\']rand["\'][^>]*value=["\']([^"\']*)["\']', html)
57
+ return m.group(1) if m else None
58
+
59
+
60
+ def parse_csrf_hash(html: str) -> str | None:
61
+ """Scrape the CSRF token from ``<input name="hash" value="...">``."""
62
+ m = re.search(r'name=["\']hash["\'][^>]*value=["\']([^"\']*)["\']', html)
63
+ return m.group(1) if m else None
64
+
65
+
66
+ def parse_port_status(html: str) -> list[PortStatus]:
67
+ """dashboard.cgi ``portID`` rows: [2]=port,[3]=link/speed,[4]=admin,[5]=name."""
68
+ rows = _ROW_RE.findall(html)
69
+ if not rows:
70
+ raise HttpUnexpectedPageError(
71
+ 'dashboard.cgi: expected <tr class="portID"> rows, found none'
72
+ )
73
+ out: list[PortStatus] = []
74
+ for row in rows:
75
+ c = _cells(row)
76
+ if len(c) < 5:
77
+ raise HttpUnexpectedPageError(
78
+ f"dashboard.cgi: expected >=5 <td> columns per portID row, got {len(c)}"
79
+ )
80
+ port = _int(c[1])
81
+ if port is None:
82
+ raise HttpUnexpectedPageError(
83
+ f"dashboard.cgi: could not parse a port number from column {c[1]!r}"
84
+ )
85
+ link_text = c[2].lower()
86
+ link_up = "up" in link_text
87
+ speed = _int(c[2]) if link_up else None
88
+ out.append(
89
+ PortStatus(
90
+ port=port,
91
+ name=c[4] or None,
92
+ admin_enabled=c[3].lower().startswith("enable"),
93
+ link_up=link_up,
94
+ speed_mbps=speed,
95
+ )
96
+ )
97
+ return out
98
+
99
+
100
+ def parse_port_stats(html: str) -> list[PortStats]:
101
+ """portStatistics.cgi ``portID`` rows: [1]=port,[2]=rx,[3]=tx,[4]=crc."""
102
+ rows = _ROW_RE.findall(html)
103
+ if not rows:
104
+ raise HttpUnexpectedPageError(
105
+ 'portStatistics.cgi: expected <tr class="portID"> rows, found none'
106
+ )
107
+ out: list[PortStats] = []
108
+ for row in rows:
109
+ c = _cells(row)
110
+ if len(c) < 4:
111
+ raise HttpUnexpectedPageError(
112
+ f"portStatistics.cgi: expected >=4 <td> columns per portID "
113
+ f"row, got {len(c)}"
114
+ )
115
+ port = _int(c[0])
116
+ if port is None:
117
+ raise HttpUnexpectedPageError(
118
+ f"portStatistics.cgi: could not parse a port number from "
119
+ f"column {c[0]!r}"
120
+ )
121
+ out.append(
122
+ PortStats(
123
+ port=port,
124
+ rx_bytes=_int(c[1]),
125
+ tx_bytes=_int(c[2]),
126
+ rx_packets=None,
127
+ tx_packets=None,
128
+ rx_errors=_int(c[3]),
129
+ tx_errors=None,
130
+ )
131
+ )
132
+ return out
133
+
134
+
135
+ def parse_poe_status(html: str) -> list[PoEStatus]:
136
+ """getPoePortStatus.cgi ``portID`` rows: [1]=port,[2]=state,[3]=power_mw."""
137
+ rows = _ROW_RE.findall(html)
138
+ if not rows:
139
+ raise HttpUnexpectedPageError(
140
+ 'getPoePortStatus.cgi: expected <tr class="portID"> rows, found none'
141
+ )
142
+ out: list[PoEStatus] = []
143
+ for row in rows:
144
+ c = _cells(row)
145
+ if len(c) < 3:
146
+ raise HttpUnexpectedPageError(
147
+ f"getPoePortStatus.cgi: expected >=3 <td> columns per "
148
+ f"portID row, got {len(c)}"
149
+ )
150
+ port = _int(c[0])
151
+ if port is None:
152
+ raise HttpUnexpectedPageError(
153
+ f"getPoePortStatus.cgi: could not parse a port number "
154
+ f"from column {c[0]!r}"
155
+ )
156
+ detect = _DETECT_TEXT.get(c[1].lower(), PoEDetect.UNKNOWN)
157
+ out.append(
158
+ PoEStatus(
159
+ port=port,
160
+ admin_enabled=detect is not PoEDetect.DISABLED,
161
+ detect=detect,
162
+ power_mw=_int(c[2]),
163
+ )
164
+ )
165
+ return out
166
+
167
+
168
+ def parse_pvids(html: str) -> list[tuple[int, int]]:
169
+ """portPVID.cgi rows: ``sel="text"`` cell = port, ``sel="input"`` cell = PVID."""
170
+ rows = _ROW_RE.findall(html)
171
+ if not rows:
172
+ raise HttpUnexpectedPageError(
173
+ 'portPVID.cgi: expected <tr class="portID"> rows, found none'
174
+ )
175
+ out: list[tuple[int, int]] = []
176
+ for m in re.finditer(
177
+ r'<td[^>]*sel="text"[^>]*>(\d+).*?</td>\s*<td[^>]*sel="input"[^>]*>(\d+)</td>',
178
+ html,
179
+ re.DOTALL,
180
+ ):
181
+ out.append((int(m.group(1)), int(m.group(2))))
182
+ if not out:
183
+ raise HttpUnexpectedPageError(
184
+ 'portPVID.cgi: expected <td sel="text"> and <td sel="input"> '
185
+ "cells in portID rows, found none matching"
186
+ )
187
+ return out
188
+
189
+
190
+ def parse_vlan_ids(html: str) -> list[int]:
191
+ """8021qCf.cgi VLAN checkboxes: ``name="vlanckN" value="VID"``."""
192
+ matches = list(re.finditer(r'name="vlanck\d+"[^>]*value="(\d+)"', html))
193
+ if not matches:
194
+ raise HttpUnexpectedPageError(
195
+ '8021qCf.cgi: expected at least one name="vlanckN" checkbox, found none'
196
+ )
197
+ return sorted({int(m.group(1)) for m in matches})
198
+
199
+
200
+ def parse_selected_vlan(html: str) -> int | None:
201
+ """8021qMembe.cgi selected VLAN in the dropdown."""
202
+ m = re.search(r'<option[^>]*selected[^>]*value="(\d+)"', html)
203
+ if m:
204
+ return int(m.group(1))
205
+ m = re.search(r'<option[^>]*value="(\d+)"[^>]*selected', html)
206
+ return int(m.group(1)) if m else None
207
+
208
+
209
+ def parse_membership(html: str, port_count: int) -> dict[int, VlanMode]:
210
+ """8021qMembe.cgi ``hiddenMem`` string: per-port 1=Untagged/2=Tagged/3=Excluded."""
211
+ m = re.search(r'id="hiddenMem"[^>]*value="([^"]*)"', html) or re.search(
212
+ r'name="hiddenMem"[^>]*value="([^"]*)"', html
213
+ )
214
+ if not m:
215
+ raise HttpUnexpectedPageError(
216
+ "8021qMembe.cgi: expected a hiddenMem input with the per-port wire "
217
+ "codes, found none"
218
+ )
219
+ raw = m.group(1)
220
+ if len(raw) < port_count:
221
+ raise HttpUnexpectedPageError(
222
+ f"8021qMembe.cgi: hiddenMem value {raw!r} has fewer than "
223
+ f"port_count={port_count} codes"
224
+ )
225
+ result: dict[int, VlanMode] = {}
226
+ for i, ch in enumerate(raw[:port_count]):
227
+ mode = _WIRE_TO_MODE.get(ch)
228
+ if mode is None:
229
+ raise HttpUnexpectedPageError(
230
+ f"8021qMembe.cgi: unknown VLAN wire code {ch!r} at port {i + 1}"
231
+ )
232
+ result[i + 1] = mode
233
+ return result
234
+
235
+
236
+ def parse_reboot_ok(html: str) -> bool:
237
+ """A reboot response that does not contain an error banner."""
238
+ return "error" not in html.lower()
@@ -0,0 +1,29 @@
1
+ """Transport-agnostic web-UI session seam (Protocols only, no I/O).
2
+
3
+ Both the sync (``httpx.Client``) and async (``httpx.AsyncClient``) transports
4
+ implement these. Readers/writers depend only on these three methods, so the
5
+ pure protocol layer is the single shared codebase across sync and async.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from typing import Protocol
10
+
11
+
12
+ class HttpSession(Protocol):
13
+ """Synchronous authenticated web-UI session for one switch."""
14
+
15
+ def login(self) -> None: ...
16
+
17
+ def get_page(self, path: str) -> str: ...
18
+
19
+ def post_form(self, path: str, data: dict[str, str]) -> str: ...
20
+
21
+
22
+ class AsyncHttpSession(Protocol):
23
+ """Asynchronous authenticated web-UI session for one switch."""
24
+
25
+ async def login(self) -> None: ...
26
+
27
+ async def get_page(self, path: str) -> str: ...
28
+
29
+ async def post_form(self, path: str, data: dict[str, str]) -> str: ...
@@ -0,0 +1,7 @@
1
+ """Pure, zero-dependency NSDP wire protocol package.
2
+
3
+ Lifted from the standalone ``gdoc2netcfg/src/nsdp`` package (protocol/types/
4
+ parsers) and extended with a write path. No network here: sockets live in
5
+ ``transport/{sync,aio}/nsdp_udp.py``. NSDP needs no third-party dependency.
6
+ """
7
+ from __future__ import annotations
@@ -0,0 +1,33 @@
1
+ """NSDP authentication.
2
+
3
+ v1 auth (older Plus firmware, incl. the GS110EMX/GS305EP this slice targets)
4
+ sends the admin password in a ``PASSWORD`` (0x000A) TLV "encrypted" by a
5
+ repeating XOR against the 19-byte key ``NtgrSmartSwitchRock`` (the only detail
6
+ the source spec ``gdoc2netcfg/docs/nsdp-protocol.md`` gives — no worked example,
7
+ no padding rule). XOR is its own inverse, so ``encode_password_v1`` both encodes
8
+ outgoing and decodes an incoming PASSWORD TLV.
9
+
10
+ UNVERIFIED / NOT IMPLEMENTED: v2 salt+hash auth (tags AUTH_V2_SALT 0x0017 /
11
+ AUTH_V2_PASSWORD 0x001A, newer firmware). The source spec names ONLY the two tag
12
+ numbers and gives no algorithm (no hash function, no salt ordering), so there is
13
+ nothing to implement honestly without a hardware capture. A switch that rejects
14
+ v1 auth returns result 0x0700 (bad password); the transport surfaces that as an
15
+ NsdpError telling the caller v2 is required (see Task 4).
16
+ """
17
+ from __future__ import annotations
18
+
19
+ V1_KEY = b"NtgrSmartSwitchRock"
20
+
21
+ AUTH_V2_UNSUPPORTED = (
22
+ "NSDP v2 salt/hash auth (tags 0x0017/0x001A) is unverified and not "
23
+ "implemented; this backend supports only v1 XOR auth"
24
+ )
25
+
26
+
27
+ def encode_password_v1(password: str) -> bytes:
28
+ """Repeating-XOR the ASCII password with ``NtgrSmartSwitchRock`` (its own
29
+ inverse). UNVERIFIED: no padding/truncation rule is documented; if a real
30
+ switch rejects this, a hardware capture is needed to confirm the exact
31
+ byte handling (see module docstring)."""
32
+ pw = password.encode("ascii")
33
+ return bytes(b ^ V1_KEY[i % len(V1_KEY)] for i, b in enumerate(pw))
@@ -0,0 +1,67 @@
1
+ """Shared NSDP transport seam: error, result check, and client Protocols.
2
+
3
+ Pure and transport-agnostic (mirrors ``protocols/snmp/client.py``). ``NsdpError``
4
+ lives here beside the protocol rather than in ``errors.py``, matching the
5
+ ``SnmpError`` precedent; both subclass the shared ``NetgearSwitchError`` base so
6
+ callers can still catch the library-wide root.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+ from typing import TYPE_CHECKING, Protocol
12
+
13
+ from ...errors import NetgearSwitchError
14
+ from .auth import AUTH_V2_UNSUPPORTED
15
+ from .write import RESULT_BAD_PASSWORD, RESULT_SUCCESS
16
+
17
+ if TYPE_CHECKING:
18
+ from .protocol import NSDPPacket, Tag, TLVEntry
19
+
20
+
21
+ class NsdpError(NetgearSwitchError):
22
+ """An NSDP transport operation failed (timeout, malformed, bad password)."""
23
+
24
+
25
+ def read_interface_mac(interface: str) -> bytes:
26
+ """Read a network interface's 6-byte MAC from sysfs (Linux)."""
27
+ text = Path(f"/sys/class/net/{interface}/address").read_text().strip()
28
+ raw = bytes.fromhex(text.replace(":", ""))
29
+ if len(raw) != 6:
30
+ raise NsdpError(f"interface {interface!r} MAC is not 6 bytes: {text!r}")
31
+ return raw
32
+
33
+
34
+ def check_result(packet: NSDPPacket) -> None:
35
+ """Raise ``NsdpError`` unless the response reports success (result 0x0000)."""
36
+ if packet.result == RESULT_SUCCESS:
37
+ return
38
+ if packet.result == RESULT_BAD_PASSWORD:
39
+ raise NsdpError(
40
+ "NSDP write rejected: bad password (result 0x0700). "
41
+ f"{AUTH_V2_UNSUPPORTED}"
42
+ )
43
+ raise NsdpError(f"NSDP request failed with result 0x{packet.result:04x}")
44
+
45
+
46
+ class NsdpClient(Protocol):
47
+ """Synchronous NSDP read client for a single switch."""
48
+
49
+ def read(self, tags: list[Tag]) -> NSDPPacket: ...
50
+
51
+
52
+ class NsdpWriteClient(NsdpClient, Protocol):
53
+ """Synchronous NSDP read+write client for a single switch."""
54
+
55
+ def write(self, tlvs: list[TLVEntry], *, password: str) -> NSDPPacket: ...
56
+
57
+
58
+ class AsyncNsdpClient(Protocol):
59
+ """Asynchronous NSDP read client for a single switch."""
60
+
61
+ async def read(self, tags: list[Tag]) -> NSDPPacket: ...
62
+
63
+
64
+ class AsyncNsdpWriteClient(AsyncNsdpClient, Protocol):
65
+ """Asynchronous NSDP read+write client for a single switch."""
66
+
67
+ async def write(self, tlvs: list[TLVEntry], *, password: str) -> NSDPPacket: ...