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,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: ...
|