python-netgear-switch-library 0.0.post353__py3-none-any.whl → 0.0.post355__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.
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.0.post353'
22
- __version_tuple__ = version_tuple = (0, 0, 'post353')
21
+ __version__ = version = '0.0.post355'
22
+ __version_tuple__ = version_tuple = (0, 0, 'post355')
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -101,6 +101,11 @@ class Operation:
101
101
 
102
102
  _CLI_BACKENDS = frozenset({Backend.SSH, Backend.TELNET, Backend.CONSOLE})
103
103
 
104
+ #: HTTP writes implemented by scraping the Plus dialect's CSRF token. A dialect
105
+ #: without that token cannot serve any of them -- see
106
+ #: ``endpoints.dialect_has_csrf_hash`` for the measurement.
107
+ _CSRF_HTTP_WRITES = frozenset({"create_vlan", "delete_vlan"})
108
+
104
109
  READ_OPERATIONS: tuple[Operation, ...] = (
105
110
  Operation("get_ports", OperationKind.READ, "Per-port link/admin status"),
106
111
  Operation("get_stats", OperationKind.READ, "Per-port octet/packet counters"),
@@ -351,7 +356,7 @@ def _http_path_for(spec: HttpModelSpec, op: Operation) -> str | None:
351
356
 
352
357
  def _http_support(model: SwitchModel, op: Operation) -> tuple[Support, str]:
353
358
  from .http_write import CERT_UPLOAD_KNOWN_UNIMPLEMENTED
354
- from .protocols.http.endpoints import http_spec
359
+ from .protocols.http.endpoints import dialect_has_csrf_hash, http_spec
355
360
 
356
361
  spec = http_spec(model)
357
362
  if not spec.reads_verified:
@@ -374,6 +379,17 @@ def _http_support(model: SwitchModel, op: Operation) -> tuple[Support, str]:
374
379
  f"this model takes a certificate by {mechanism}, not over the "
375
380
  "web UI -- use upload_certificate_scp",
376
381
  )
382
+ if op.name in _CSRF_HTTP_WRITES and not dialect_has_csrf_hash(spec.html_dialect):
383
+ # These writers scrape an <input name="hash"> before posting, and this
384
+ # dialect's pages do not carry one -- MEASURED on gsm7252ps and
385
+ # gs110emx, see endpoints.dialect_has_csrf_hash. Driving them raises
386
+ # HttpUnexpectedPageError on real hardware, so claiming support here
387
+ # would publish a support table that contradicts the device.
388
+ return (
389
+ Support.UNSUPPORTED,
390
+ f"model {model.key!r} web UI carries no CSRF 'hash' token, which "
391
+ f"the HTTP {op.name} writer requires",
392
+ )
377
393
  path = _http_path_for(spec, op)
378
394
  if path is None:
379
395
  return (
@@ -268,6 +268,25 @@ def _check_goahead_upload_response(text: str) -> None:
268
268
  )
269
269
 
270
270
 
271
+ def _require_csrf_dialect(spec: HttpModelSpec, model_key: str, op: str) -> None:
272
+ """Refuse a hash-scraping write on a dialect whose pages carry no token.
273
+
274
+ This is a CAPABILITY refusal, not a surprise, so it raises
275
+ UnsupportedCapabilityError rather than HttpUnexpectedPageError: the writer
276
+ is built on the Plus form, and MEASURED probes of gsm7252ps and gs110emx
277
+ found no ``<input name="hash">`` anywhere in their write pages. Saying so by
278
+ type is what lets the facade, the capability table and the caller agree --
279
+ and what stops a bare "unexpected page" from reading like a transient fault.
280
+ """
281
+ from .protocols.http.endpoints import dialect_has_csrf_hash
282
+
283
+ if not dialect_has_csrf_hash(spec.html_dialect):
284
+ raise UnsupportedCapabilityError(
285
+ f"model {model_key!r} web UI carries no CSRF 'hash' token, which "
286
+ f"the HTTP {op} writer requires"
287
+ )
288
+
289
+
271
290
  def _csrf(html: str) -> str:
272
291
  token = parse.parse_csrf_hash(html)
273
292
  if token is None:
@@ -747,6 +766,7 @@ class HttpWriter:
747
766
  return shown
748
767
 
749
768
  def create_vlan(self, vlan: int, name: str, *, force: bool = False) -> None:
769
+ _require_csrf_dialect(self._spec, self.model.key, "create_vlan")
750
770
  del name, force # web UI 8021qCf.cgi has no VLAN-name field (GROUNDED).
751
771
  path = _require_path(self.model.key, self._spec.vlan_config_path, "VLAN config")
752
772
  page = self.session.get_page(path)
@@ -760,6 +780,7 @@ class HttpWriter:
760
780
  )
761
781
 
762
782
  def delete_vlan(self, vlan: int, *, force: bool = False) -> None:
783
+ _require_csrf_dialect(self._spec, self.model.key, "delete_vlan")
763
784
  del force # VLAN delete disruptiveness is guarded per-member elsewhere.
764
785
  path = _require_path(self.model.key, self._spec.vlan_config_path, "VLAN config")
765
786
  page = self.session.get_page(path)
@@ -1215,6 +1236,7 @@ class AsyncHttpWriter:
1215
1236
  return shown
1216
1237
 
1217
1238
  async def create_vlan(self, vlan: int, name: str, *, force: bool = False) -> None:
1239
+ _require_csrf_dialect(self._spec, self.model.key, "create_vlan")
1218
1240
  del name, force
1219
1241
  path = _require_path(self.model.key, self._spec.vlan_config_path, "VLAN config")
1220
1242
  page = await self.session.get_page(path)
@@ -1228,6 +1250,7 @@ class AsyncHttpWriter:
1228
1250
  )
1229
1251
 
1230
1252
  async def delete_vlan(self, vlan: int, *, force: bool = False) -> None:
1253
+ _require_csrf_dialect(self._spec, self.model.key, "delete_vlan")
1231
1254
  del force
1232
1255
  path = _require_path(self.model.key, self._spec.vlan_config_path, "VLAN config")
1233
1256
  page = await self.session.get_page(path)
@@ -140,6 +140,27 @@ class HtmlDialect(enum.Enum):
140
140
  GOAHEAD_XML = "goahead_xml"
141
141
 
142
142
 
143
+ def dialect_has_csrf_hash(dialect: HtmlDialect) -> bool:
144
+ """Whether this dialect's pages carry an ``<input name="hash">`` CSRF token.
145
+
146
+ ``http_write`` scrapes that token before every form post, so a dialect
147
+ without one cannot be driven by those writers at all.
148
+
149
+ MEASURED 2026-08-02, not inferred. Live probes found NO hash on any write
150
+ page of gsm7252ps (10.1.5.22: vlanStatus, poeInterfaceConfiguration,
151
+ portPvidConfiguration, vlan_port_cfg, portsConfiguration) nor of gs110emx
152
+ (10.1.5.25: Cf8021q, vlan_pvidsetting). Only the Plus ``.cgi`` pages have
153
+ it -- the surface ``HttpWriter`` was originally written against.
154
+
155
+ ONE definition, read by the capability oracle AND by the virtual switch, so
156
+ the mock can never emit a token the hardware lacks. That divergence is
157
+ exactly how HTTP ``create_vlan`` passed the entire test suite while failing
158
+ on all four FASTPATH switches.
159
+ """
160
+ return dialect in {HtmlDialect.STANDARD, HtmlDialect.GS105PE}
161
+
162
+
163
+
143
164
  @dataclass(frozen=True)
144
165
  class XuiMgmtIpFields:
145
166
  """Which fields of a FASTPATH XUI management-IP page carry what.
@@ -39,8 +39,40 @@ def _hash_input() -> str:
39
39
  return f'<input type="hidden" name="hash" value="{_HASH}">'
40
40
 
41
41
 
42
+ def _has_csrf_hash(spec: HttpModelSpec) -> bool:
43
+ """Whether this model's real web UI carries an ``<input name="hash">``.
44
+
45
+ ONLY the Plus ``.cgi`` dialects do. MEASURED 2026-08-02: a live capture of
46
+ the FASTPATH ``vlanStatus.html`` on gsm7252ps (10.1.5.22, committed as
47
+ ``tests/fixtures/http/gsm7252ps_vlan_status_live.html``) carries no ``hash``
48
+ input anywhere -- only ``applet_port``, ``applet_unit``, ``dbgopt`` and the
49
+ XUI cell hiddens.
50
+
51
+ This mock used to emit the token on EVERY page regardless of dialect, and
52
+ that is how ``HttpWriter.create_vlan`` -- which scrapes the token and is
53
+ written for the Plus form -- passed here while failing on real FASTPATH
54
+ hardware with "no CSRF 'hash' token on page before write". The mock and the
55
+ writer agreed with each other while both disagreed with the device, which
56
+ is exactly the failure principle 5 exists to prevent. Emitting it only
57
+ where the hardware does makes the mock refuse the same write the switch
58
+ refuses.
59
+ """
60
+ from ..protocols.http.endpoints import dialect_has_csrf_hash
61
+
62
+ return dialect_has_csrf_hash(spec.html_dialect)
63
+
64
+
42
65
  def render_page(
43
66
  state: VirtualSwitchState, spec: HttpModelSpec, path: str, form: dict[str, str]
67
+ ) -> str:
68
+ html = _render_page_body(state, spec, path, form)
69
+ if not _has_csrf_hash(spec):
70
+ html = html.replace(_hash_input(), "")
71
+ return html
72
+
73
+
74
+ def _render_page_body(
75
+ state: VirtualSwitchState, spec: HttpModelSpec, path: str, form: dict[str, str]
44
76
  ) -> str:
45
77
  if path == spec.dashboard_path:
46
78
  return _render_dashboard(state)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-netgear-switch-library
3
- Version: 0.0.post353
3
+ Version: 0.0.post355
4
4
  Summary: Python library and CLI to query and control Netgear switches over SNMP, NSDP and HTTP.
5
5
  Author-email: Tim Ansell <me@mith.ro>
6
6
  License-Expression: Apache-2.0
@@ -1,14 +1,14 @@
1
1
  netgear_switch/__init__.py,sha256=3P68qR3Rcf9zl5u4_SMkmloD8MnzZi6VWv7n4AMb_-4,5835
2
2
  netgear_switch/_dispatch.py,sha256=JO30TCPjE_1Y10SIGEkQybPT5EQB5z0LGfyT5YDQYMc,12615
3
- netgear_switch/_version.py,sha256=RQfwfyXRGb0ZUVK1NxgTp_zOxRf2JaUrtvY7vGNbuek,534
3
+ netgear_switch/_version.py,sha256=zWbegVLGRk_tDrqYbjadt8rHBhj6Vv_pjARz9Mr8w0g,534
4
4
  netgear_switch/aio_api.py,sha256=5b7a6LPT_LkMmcRdCzyPRhiE9nte9TseOFCcmlTj-5o,29561
5
- netgear_switch/capabilities.py,sha256=duMr2-MVJIcDzxmppdBplysq_e-_Q4E1W1T7w1guVww,20594
5
+ netgear_switch/capabilities.py,sha256=ZG9QX0k0zZTelaat70Kfn_bW3UZ9Dk4Pfq49CiqzdAc,21524
6
6
  netgear_switch/cli_read.py,sha256=EpbFLYPSA_tErwMfqHJBOjonhUVvPpS-pmlnYGQ73W0,6477
7
7
  netgear_switch/cli_write.py,sha256=NGcitE5ZRfFSohOn38mLUcFsshofJNfI9XxcbQsd5Ss,33156
8
8
  netgear_switch/config.py,sha256=z2vV1LqsLTb83ekEJwy_nnkeUlxcknicmkmKfILH5kc,5787
9
9
  netgear_switch/errors.py,sha256=zb9fFU71o69NXBhH51OOe5tXVp1UigMd2U64TN5cTW0,2203
10
10
  netgear_switch/http_read.py,sha256=LsgticMEeFdeSZgQwx16RSYc81i4wlHoe6tm_wStefg,39779
11
- netgear_switch/http_write.py,sha256=bkg1w8kPvUK-Llawv-DYkdIK5TOPlSgKFX7eOyoqAfU,61669
11
+ netgear_switch/http_write.py,sha256=dcMhpJ-qKJh-bE1fS0B06JoDeZpDhH9WXmcAH3jsqbU,62884
12
12
  netgear_switch/models.py,sha256=5esfLwV47kM_8kyVeVAOihhS9nxB0CHj0kaRbd64388,8557
13
13
  netgear_switch/nsdp_read.py,sha256=CXJ4FrIWvEnk1slERgJbZRD2G189vgE6t0YclrCMoH4,15467
14
14
  netgear_switch/nsdp_write.py,sha256=NaMvrGbDqiej5gk27pS24UWY_T2WdamwK1kHX65e884,21366
@@ -31,7 +31,7 @@ netgear_switch/protocols/cli/commands.py,sha256=88Dt506mDAMUEW6fpmntjarAxnHyjVLJ
31
31
  netgear_switch/protocols/cli/parse.py,sha256=dNxnzy49IsPrO5oYXWT7NLYsXZySRz0S_0IZUzwU0D8,38864
32
32
  netgear_switch/protocols/http/__init__.py,sha256=n_iSvNTdSqeEE8U2hbk5SYSNXrWHDYzlqok89FVnK10,85
33
33
  netgear_switch/protocols/http/crypt.py,sha256=zwFqE9ff49-j-qlvo7k-ydcG3z1l2SNJ8WLel65-w58,976
34
- netgear_switch/protocols/http/endpoints.py,sha256=krxd8cDbvOPecHQzk8SMbWOrdKmqlAWKRH9YcYZmZ1c,52469
34
+ netgear_switch/protocols/http/endpoints.py,sha256=mNvzll3pQLZC5rKUOLcf994v7LR8nKJ288MrYd9akVQ,53466
35
35
  netgear_switch/protocols/http/forms.py,sha256=a218L58_D62pgYACtlVzGpej88dQLEfTXsQqM7K3Pww,11228
36
36
  netgear_switch/protocols/http/parse.py,sha256=1qwGOX0l1kFxvwFWZXAFWoTnT4iM0qorIuJanof0qSk,116681
37
37
  netgear_switch/protocols/http/session.py,sha256=kJto8197Q3_ArIr3DnwapmVPZP--zG-T17uCxxqyiHM,1766
@@ -67,7 +67,7 @@ netgear_switch/virtual/cli_fastpath.py,sha256=Mk1ER8_PRp2apT-l85Imnd8fPhQcM05RaM
67
67
  netgear_switch/virtual/seed.py,sha256=xoaaQbgVQsN1lAzq7D9yPxQzRAs3B8RFOrMn-ikmOz4,96821
68
68
  netgear_switch/virtual/server.py,sha256=L6pkdsK48nVhSOVx-TCNLQTtjTJkud-xi-EuRh5CbiQ,9335
69
69
  netgear_switch/virtual/state.py,sha256=I0U9n3rqBQaTqwfoaKDdEb6D3Zt45s_ha6eBxBnCLdc,74611
70
- netgear_switch/virtual/web.py,sha256=abRn0pr_T_SycW2khBt8lvErPiq1KdiMVqSnloeG-WY,7323
70
+ netgear_switch/virtual/web.py,sha256=hMrDQgVP2DDQO-2ebE1z4NXSuCvZozzookRSfkUvx-k,8737
71
71
  netgear_switch/virtual/web_fastpath_vlan.py,sha256=W5TsIrjRzt57jno-YAjXUiuXW_6IwIzTzPqs1N2O_Pw,19443
72
72
  netgear_switch/virtual/web_fastpath_xui.py,sha256=B2K3c_CJ5dzOzCw8ELIdMiUyCj1wmw_psp7luNh70s0,11208
73
73
  netgear_switch/virtual/web_gs105pe.py,sha256=4BJ7xEkTdnQiI5r20doFv-ErLEr5BDLMq4fmCayqMEs,7568
@@ -84,8 +84,8 @@ netgear_switch/virtual/faces/mibview.py,sha256=g4EsSfXO6ojyPDT633lEkNVmPcqzU0Vdb
84
84
  netgear_switch/virtual/faces/nsdp.py,sha256=WX9w1YWArUvm4-L2Y7QI8njvBRD4t0TVFUL2V0yuQmI,10395
85
85
  netgear_switch/virtual/faces/snmp.py,sha256=y8N3rDwO3AuHWi9Ke10WLG1yzZITpn363PunIG_ZuSk,21252
86
86
  netgear_switch/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
87
- python_netgear_switch_library-0.0.post353.dist-info/METADATA,sha256=Wcyk29yyIW62th7llMNa3SJx29yf7ODE-Ve1AR29KRY,6682
88
- python_netgear_switch_library-0.0.post353.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
89
- python_netgear_switch_library-0.0.post353.dist-info/entry_points.txt,sha256=mf6HFDGVV0KIDI0dHkh0BNcCjHkSg1lhE70xTzB47WQ,96
90
- python_netgear_switch_library-0.0.post353.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
91
- python_netgear_switch_library-0.0.post353.dist-info/RECORD,,
87
+ python_netgear_switch_library-0.0.post355.dist-info/METADATA,sha256=TIr7LAfmhFmpnN_MfzW95BARs64CxWKGG0a60Cywk24,6682
88
+ python_netgear_switch_library-0.0.post355.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
89
+ python_netgear_switch_library-0.0.post355.dist-info/entry_points.txt,sha256=mf6HFDGVV0KIDI0dHkh0BNcCjHkSg1lhE70xTzB47WQ,96
90
+ python_netgear_switch_library-0.0.post355.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
91
+ python_netgear_switch_library-0.0.post355.dist-info/RECORD,,