Source code for netgear_switch.virtual.web_gs110emx

"""Byte-faithful GS110EMX web-UI page templates (Gambit token session).

The literal HTML in ``web_gs110emx_templates.py`` is the REAL captured
content from a physical GS110EMX (``tests/fixtures/http/gs110emx_*.html``)
with only the dynamic values swapped for marker placeholders -- everything
else (JS boilerplate, attribute names, and critically the malformed
*never-closed* ``<tr class="portID">`` rows on interface_stats.html -- see
``protocols/http/parse.py``'s ``_OPEN_ROW_RE``) is copied byte-for-byte from
the capture, so the mock is byte-equivalent to real hardware whenever seeded
with the same values. Substitution is plain ``str.replace`` (not
``.format()``/f-strings) because the captured pages' inline JavaScript is
full of literal ``{``/``}`` characters that would need escaping.

One known, deliberate byte-level deviation: the real capture's LAST
``portID`` row (port 10) is followed by 2 fewer whitespace characters before
``</table>`` than every other row -- a capture idiosyncrasy of the real
device, not a parsing-relevant difference. ``render_interface_stats`` renders
every row (including the last) with the same trailing whitespace for
simplicity, so a byte-diff against the original 10-port capture differs by
exactly those 2 bytes.
"""

from __future__ import annotations

from typing import TYPE_CHECKING

from . import web_gs110emx_templates as _t

if TYPE_CHECKING:
    from .state import VirtualSwitchState


[docs] def render_login(rand: str) -> str: """GET / login page -- byte-identical to gs110emx_login.html but for the ``rand`` nonce.""" return _t.LOGIN.replace("__RAND__", rand)
[docs] def render_redirect(token: str) -> str: """POST /redirect.html login response -- byte-identical to gs110emx_redirect.html but for the Gambit token. ``token=""`` (a rejected login) renders a Gambit field with an empty value, which ``parse.parse_gambit_token`` reads back as falsy.""" return _t.REDIRECT.replace("__GAMBIT__", token)
def _format_mac(raw: bytes) -> str: return ":".join(f"{b:02x}" for b in raw)
[docs] def render_sysinfo(state: VirtualSwitchState, token: str) -> str: """GET /iss/specific/sysInfo.html?Gambit=<token> -- byte-identical to gs110emx_sysinfo.html but for the state-driven device identity / mgmt-IP fields. ``dhcp_select`` mirrors the captured page's ``data-select-value`` attribute (0=static/Disable, 1=DHCP/Enable) -- see ``protocols/http/parse.py``'s ``parse_sysinfo``/``HttpSysInfo`` for the read-side grounding of this convention.""" dhcp_select = "1" if state.mgmt.mode == "dhcp" else "0" return ( _t.SYSINFO.replace("__GAMBIT__", token) .replace("__PRODUCT_NAME__", state.model_name or "GS110EMX") .replace("__SWITCH_NAME__", state.hostname) .replace("__SERIAL__", state.serial) .replace("__MAC__", _format_mac(state.nsdp_mac)) .replace("__FIRMWARE__", state.firmware) .replace("__DHCP_SELECT__", dhcp_select) .replace("__IP__", state.mgmt.address) .replace("__NETMASK__", state.mgmt.netmask) .replace("__GATEWAY__", state.mgmt.gateway) )
def _speed_mbps_to_text(mbps: int) -> str: """State ``speed`` (Mbps) -> the port_settings.html speed text. Inverse of ``parse._speed_text_to_mbps`` so a round trip (state -> HTTP page -> reader) reproduces the state value, and so HTTP ``speed_mbps`` equals the NSDP backend's for the same state (the HTTP<->NSDP cross-verification).""" if mbps >= 1000 and mbps % 1000 == 0: return f"{mbps // 1000}G Full" return f"{mbps}M Full"
[docs] def render_port_settings(state: VirtualSwitchState, token: str) -> str: """GET port_settings.html -- port link/speed/description from state, so the HTTP port-status read matches the NSDP PORT_STATUS read on this switch.""" rows = "".join( _t.PORT_SETTINGS_ROW.replace("__PORT__", str(port)) .replace("__DESC__", sim.description or sim.name or "") .replace("__LINK__", "Up" if sim.link else "Down") # The mode cell IS the admin state on this model (its Physical Mode # select's only "off" option is Disable) -- see parse_gs110emx_port_status. .replace("__MODE__", "Auto" if sim.admin else "Disable") .replace("__PHYSMODE__", "1" if sim.admin else "6") .replace( "__SPEED__", _speed_mbps_to_text(sim.speed) if sim.link else "No Speed", ) for port, sim in sorted(state.ports.items()) ) return ( _t.PORT_SETTINGS_PREFIX.replace("__GAMBIT__", token) + rows + _t.PORT_SETTINGS_SUFFIX )
# The GS110EMX's PORT_CTRL_MODE wire values (from the firmware's own # function.js: PHYSICAL_MODE 1 "Auto" -> 1, PHYSICAL_MODE 6 "Disable" -> 3). _EMX_CTRL_MODE_DISABLE = "3"
[docs] def apply_port_settings(state: VirtualSwitchState, form: dict[str, str]) -> str: """Apply a ``port_settings.html`` POST; returns the page's reply BODY. The real page answers an AJAX apply with a bare ``SUCCESS`` (its own JS checks ``resText != "SUCCESS"``), not a re-rendered page. ``PORT_NO`` is the SEMICOLON-TERMINATED selected-port list its ``saveSelectedPorts()`` builds (``"3;"``), and a body that sends a bare number selects nothing and applies nothing -- reproduced here, because that is exactly the mistake that a lenient mock would have hidden (it was caught on real hardware instead). """ if form.get("ACTION") != "apply": return "SUCCESS" raw = form.get("PORT_NO", "") if not raw.endswith(";"): return "SUCCESS" # nothing selected -- the switch applies nothing enabled = form.get("PORT_CTRL_MODE") != _EMX_CTRL_MODE_DISABLE for part in raw.split(";"): if not part.strip().isdigit(): continue port = int(part) if port in state.ports: state.ports[port].admin = enabled return "SUCCESS"
[docs] def render_pvid(state: VirtualSwitchState, token: str) -> str: """GET vlan_pvidsetting.html -- per-port PVID from state.""" rows = "".join( _t.PVID_ROW.replace("__PORT__", str(port)).replace("__PVID__", str(pvid)) for port, pvid in sorted(state.pvids.items()) ) return _t.PVID_PREFIX.replace("__GAMBIT__", token) + rows + _t.PVID_SUFFIX
[docs] def render_cf8021q(state: VirtualSwitchState, token: str) -> str: """GET Cf8021q.html -- the VLAN list (with member ports) from state. The reader only scrapes the VID column (``parse_gs110emx_vlan_ids``); the member list is rendered for fidelity.""" rows = "".join( _t.CF8021Q_ROW.replace("__VID__", str(vid)).replace( "__MEMBERS__", " ".join(str(p) for p in sorted(vsim.member)) + " " ) for vid, vsim in sorted(state.vlans.items()) ) return _t.CF8021Q_PREFIX.replace("__GAMBIT__", token) + rows + _t.CF8021Q_SUFFIX
[docs] def render_vlan_membership( state: VirtualSwitchState, token: str, selected_vid: int ) -> str: """POST vlanMembership.html (VLAN_ID=<selected_vid>) -- the per-port ``hiddenMem`` wire codes (1=untagged, 2=tagged, 3=excluded) for the selected VLAN, plus the full VLAN <option> list. The wire codes are the SAME scheme gs305ep's 8021qMembe.cgi uses, so ``parse.parse_membership`` reads it back and the resulting VLANInfo matches the NSDP VLAN_MEMBERS read. """ from ..registry import get_model port_count = get_model(state.model_key).port_count vsim = state.vlans.get(selected_vid) codes = [] for port in range(1, port_count + 1): if vsim is not None and port in vsim.untagged: codes.append("1") elif vsim is not None and port in vsim.member: codes.append("2") else: codes.append("3") options = "".join( f'<option value = "{vid}">{vid} </option>\n' for vid in sorted(state.vlans) ) return ( _t.VLANMEM_PAGE.replace("__GAMBIT__", token) .replace("__VLAN_OPTIONS__", options) .replace("__HIDDENMEM__", "".join(codes)) )
[docs] def render_interface_stats(state: VirtualSwitchState, token: str) -> str: """GET /iss/specific/interface_stats.html?Gambit=<token> -- byte-identical to gs110emx_interface_stats.html but for the per-port counters. The real device NEVER closes a ``<tr class="portID">`` with ``</tr>`` (rows run on until the next ``<tr>`` or the table close); this reproduces that exact malformed-but-real shape row-for-row, which is why ``parse.parse_interface_stats`` (not gs305ep's ``parse_port_stats``) is needed to read it back. Missing counters (``None``) render as ``0``, matching the real device's own zeroed idle-port rows.""" rows = "".join( '<tr class="portID"> \n' + _t.STATS_ROW.replace("__PORT__", str(port)) .replace("__RX__", str(sim.rx_octets or 0)) .replace("__TX__", str(sim.tx_octets or 0)) .replace("__CRC__", str(sim.rx_errors or 0)) for port, sim in sorted(state.ports.items()) ) return _t.STATS_PREFIX.replace("__GAMBIT__", token) + rows + _t.STATS_SUFFIX