"""The managed FASTPATH "VLAN Membership" page, rendered from + applied to state.
Reproduces ``switching/dot1q/vlan_port_cfg.html`` (GET) and its
``..._rw.html`` form target (POST, used for BOTH a VLAN-select re-render and an
apply) as the real firmware serves them. Grounded in live captures taken
2026-07-30 from all four managed switches -- gsm7252ps 10.1.5.22, gsm7228ps
(S3300-52X) 10.1.5.11, m4300-24x 10.1.5.13 and m4300-16x 10.1.5.20:49152 -- which
are checked in as ``tests/fixtures/http/*vlan[Pp]ort[Cc]fg*.html``.
The behaviours below are reproduced BECAUSE hardware does them, and each one is
something a lenient mock would have hidden:
* **Two different views of the same VLAN.** ``hiddenTagged``/``hiddenUnTagged``
are the CURRENT (operational) egress lists; ``hiddenMem`` and the port grid are
the CONFIGURED participation. They genuinely differ -- see
``state.VlanSim.configured_only``, seeded from the real GSM7252PS.
* **``submt`` is the apply flag.** ``submt=0`` (what the VLAN ``<select>``'s
``screen_refresh()`` posts) re-renders WITHOUT applying; only ``submt=16``
(``0x10``, what ``submitform()`` sets) writes. A mock that applied on every
POST would let a broken reader silently corrupt VLANs and still pass.
* **The page shows whichever VLAN ``vlanId`` selected**, and an unknown VLAN
falls back to the lowest -- so a reader that forgets to check which VLAN came
back gets caught here instead of on hardware.
* **Two grid encodings, two index bases.** Older firmware (gsm7252ps) emits
``toggleImageFirst(this,<0-based slot>,...)`` + ``grey_[btu].gif``; newer
(S3300/M4300) emits ``togImg(this,<1-based slot>,...)`` + ``switch_*.png``. Both
are rendered, per model, from the seeded ``VlanMembershipPageSim.grid``.
* **LAG pseudo-interfaces occupy hiddenMem slots after the physical ports** (64
on the gsm7252ps, 26 on the S3300, 128 on the M4300s) and are rendered in their
own grid table. A writer that assumed ``slot == port - 1`` and truncated the
string would drop them; here it cannot.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from ..protocols.http.parse import _FASTPATH_MEM_TO_MODE
from .web_gsm7228ps import _s3300_iface
if TYPE_CHECKING:
from ..protocols.http.endpoints import HttpModelSpec
from .state import VirtualSwitchState, VlanMembershipPageSim
# Wire code -> the grid-image token each firmware generation renders for it.
# Inverted from the parser's own map so the two cannot drift apart on the CODE,
# while the IMAGE NAMES are the device's (measured from the captures).
_MODE_TO_CODE = {mode: code for code, mode in _FASTPATH_MEM_TO_MODE.items()}
_GIF_IMG = {"1": "grey_t", "2": "grey_u", "3": "grey_b"}
_PNG_IMG = {"1": "tagged", "2": "untagged", "3": "blank"}
# ``submitform()`` in the firmware's rollover.js sets submt = 0x10 before
# submitting; anything else is a read-only re-render (``screen_refresh()``).
_APPLY = "16"
def _page_sim(state: VirtualSwitchState) -> VlanMembershipPageSim:
page = state.vlan_membership_page
if page is None: # pragma: no cover - guarded by the caller's spec check
raise ValueError(
f"{state.model_key!r} has no measured vlan_membership_page geometry; "
"seed one from a real capture rather than inventing a page"
)
return page
def _physical_ports(state: VirtualSwitchState) -> list[int]:
from ..registry import get_model
port_count = get_model(state.model_key).port_count
return [p for p in sorted(state.ports) if p <= port_count]
def _lag_base(state: VirtualSwitchState) -> int:
"""The 0-based ``hiddenMem`` slot where the LAG pseudo-interfaces start.
It is the number of PHYSICAL ports the page renders -- NOT
``model.port_count``. Those differ: the registry gives the M4300-24X
``port_count=28`` while the real switch's grid renders 24 cells and puts
lag 1 at slot 24 (its hiddenMem is 152 slots = 24 + 128). Using the registry
count here would shift every LAG slot by four and make the mock disagree with
the capture.
"""
return len(_physical_ports(state))
def _esc(text: str, page: VlanMembershipPageSim) -> str:
"""HTML-entity-escape a ifName list the way the newer firmware does."""
return text.replace("/", "/") if page.escape else text
def _iface(state: VirtualSwitchState, port: int) -> str:
"""The ifName the model's grid uses for a physical port.
The S3300's Smart firmware writes ``1/gN``/``1/xgN`` in the grid's ``aid``
attribute (the same names its MAC table uses); every other model writes the
FASTPATH ``1/0/N``. Both appear in the real captures.
"""
from ..protocols.http.endpoints import HtmlDialect, http_spec
from ..registry import get_model
if http_spec(get_model(state.model_key)).html_dialect is HtmlDialect.S3300:
return _s3300_iface(port)
return f"1/0/{port}"
def _codes(state: VirtualSwitchState, vid: int) -> list[str]:
"""The full ``hiddenMem`` code list for ``vid``: one per slot.
Physical ports come from the VLAN's CONFIGURED sets (``hiddenMem`` is the
configured view); the LAG slots that follow are rendered Excluded unless the
seed put a LAG ifIndex in the VLAN, in which case its position among the
VLAN's LAG members selects the slot -- the same convention
``web_gsm7252ps.render_vlans`` uses for the ``lag N`` egress names.
"""
from ..registry import get_model
page = _page_sim(state)
port_count = get_model(state.model_key).port_count
vsim = state.vlans.get(vid)
codes = [_MODE_TO_CODE[_FASTPATH_MEM_TO_MODE["3"]]] * page.slots
if vsim is None:
return codes
for port in _physical_ports(state):
if port not in vsim.configured:
continue
untagged = port in vsim.untagged or port in vsim.configured_only
codes[port - 1] = "2" if untagged else "1"
lags = [p for p in sorted(vsim.configured) if p > port_count]
for i, lag in enumerate(lags):
slot = _lag_base(state) + i
if slot < page.slots:
codes[slot] = "2" if lag in vsim.untagged else "1"
return codes
def _iface_lists(state: VirtualSwitchState, vid: int) -> tuple[str, str]:
"""``(hiddenTagged, hiddenUnTagged)`` -- the CURRENT egress ifName lists.
Built from ``member``/``untagged`` only, deliberately EXCLUDING
``configured_only``: that is precisely the divergence real firmware shows
(see the module docstring). LAG members are rendered with the model's own LAG
slot (``0/3/N`` or ``0/13/N``), which is what makes a parser that treats a
bare ``\\d+/\\d+/\\d+`` as a physical port produce phantom ports here too.
"""
from ..registry import get_model
page = _page_sim(state)
port_count = get_model(state.model_key).port_count
vsim = state.vlans.get(vid)
if vsim is None:
return "", ""
tagged: list[str] = []
untagged: list[str] = []
for port in _physical_ports(state):
if port not in vsim.member:
continue
(untagged if port in vsim.untagged else tagged).append(_iface(state, port))
lags = [p for p in sorted(vsim.member) if p > port_count]
for i, lag in enumerate(lags, start=1):
name = f"0/{page.lag_slot}/{i}"
(untagged if lag in vsim.untagged else tagged).append(name)
tail = "," if page.trailing_comma else ""
return (
_esc(",".join(tagged) + (tail if tagged else ""), page),
_esc(",".join(untagged) + (tail if untagged else ""), page),
)
def _grid_gif(state: VirtualSwitchState, codes: list[str]) -> str:
"""The older gsm7252ps grid: ``toggleImageFirst`` + ``grey_[btu].gif`` cells,
with the physical unit table and the LAG table each labelled by their first
row cell ("Port" / "LAG") -- which is what tells them apart."""
page = _page_sim(state)
base = _lag_base(state)
def cells(items: list[tuple[int, int]], unit: str) -> str:
return "".join(
f'<td><a style="cursor: pointer" onClick="toggleImageFirst('
f"this,{slot},0,'img_unit{unit}',{intf});return false\" >"
f'<img src="/base/images/{_GIF_IMG[codes[slot]]}.gif" name="imx" '
f'id="{intf}"></a></td>\n'
for slot, intf in items
)
ports = [(p - 1, p) for p in _physical_ports(state)]
# Real firmware numbers LAG grid cells with their internal interface ids
# (418..481 on the captured GSM7252PS), NOT 1..64 -- so the mock does too.
lag_id_base = 418
lags = [(base + i, lag_id_base + i) for i in range(page.slots - base)]
return (
'<table class="tableStyle" id="unit1tb"><tbody>\n'
'<tr class="font10Bold messageTableWhite">\n<td >Port</td>\n'
+ "".join(f"<td>{p}</td>\n" for _s, p in ports)
+ '</tr>\n<tr class="messageTableGrey">\n<td> </td>\n'
+ cells(ports, "1")
+ "</tr>\n</tbody></table>\n"
+ '<table class="tableStyle" id="unit25tb"><tbody>\n'
'<tr class="font10Bold messageTableWhite">\n<td >LAG</td>\n'
+ "".join(f"<td>{i + 1}</td>\n" for i in range(len(lags)))
+ '</tr>\n<tr class="messageTableGrey">\n<td> </td>\n'
+ cells(lags, "25")
+ "</tr>\n</tbody></table>\n"
)
def _grid_png(state: VirtualSwitchState, codes: list[str]) -> str:
"""The newer S3300/M4300 grid: ``aid='port-<ifName>'`` + ``togImg`` cells with
a 1-BASED hiddenMem index, and ``switch_<state>[_bottom]_inactive.png``
images (``_bottom`` on the even ports, which the real page draws on the lower
row of the port panel)."""
page = _page_sim(state)
base = _lag_base(state)
out = [
"<table class='tableStyle tableWidthAuto' id='unit1tb'>\n"
"<tr class='fontTableTitle' id='unit1_view'>\n"
"<td class='intStyle'>Ports</td>\n"
]
for port in _physical_ports(state):
bottom = "_bottom" if port % 2 == 0 else ""
out.append(
f"<td><div class='titleUp'>{port}</div>\n<div class='panel'>"
f"<a href='javascript:void(0)'><img class='panPad' "
f"aid='port-{_iface(state, port)}' "
f"src='/base/images/switch_{_PNG_IMG[codes[port - 1]]}{bottom}"
f"_inactive.png' name='imx' onclick='onClick(this); "
f'togImg(this,{port},0,"hiddenMem"); enablebtn(1);\'/></a></div></td>\n'
)
out.append("</tr>\n</table>\n")
out.append(
"<table class='tableStyle tableWidthAuto' id='unit2tb'>\n"
"<tr class='fontTableTitle'>\n<td class='intStyle'>LAG</td>\n"
)
for i in range(page.slots - base):
slot1 = base + i + 1
out.append(
f"<td><div class='titleUp'>{i + 1}</div>\n<div class='panel'>"
f"<a href='javascript:void(0)'><img class='panPad' aid='lag {i + 1}' "
f"src='/base/images/switch_{_PNG_IMG[codes[slot1 - 1]]}_inactive.png' "
f"name='imx' onclick='onClick(this); "
f'togImg(this,{slot1},0,"hiddenMem"); enablebtn(1);\'/></a></div></td>\n'
)
out.append("</tr>\n</table>\n")
return "".join(out)
def _shown_vlan(state: VirtualSwitchState, form: dict[str, str]) -> int:
"""Which VLAN this render is for.
A ``vlanId`` naming a VLAN the switch does not have falls back to the lowest
one, exactly as the firmware's ``<select>`` does -- so the reader's
"is this the VLAN I asked for?" guard is exercised rather than bypassed.
"""
requested = form.get("vlanId", "")
if requested.isdigit() and int(requested) in state.vlans:
return int(requested)
return min(state.vlans, default=1)
[docs]
def refusal(state: VirtualSwitchState, form: dict[str, str]) -> str | None:
"""The ``err_msg`` this apply would be refused with, or ``None`` if allowed.
Reproduces the M4300 firmware's precondition: a port whose ``switchport mode``
is access or trunk cannot be given explicit VLAN membership, and the web UI
reports that as ``err_flag=1`` + ``err_msg`` on an otherwise-200 page rather
than an HTTP error. Quoted verbatim from 10.1.5.13 -- see
``VirtualSwitchState.vlan_membership_locked_ports``.
"""
if form.get("submt") != _APPLY or not state.vlan_membership_locked_ports:
return None
vid = _shown_vlan(state, form)
codes = [c for c in form.get("hiddenMem", "").split(",") if c != ""]
vsim = state.vlans.get(vid)
for port in state.vlan_membership_locked_ports:
code = codes[port - 1] if 0 < port <= len(codes) else None
mode = _FASTPATH_MEM_TO_MODE.get(code or "")
if mode is None:
continue
if vsim is None or port not in vsim.configured:
was = _FASTPATH_MEM_TO_MODE["3"]
elif port in vsim.untagged or port in vsim.configured_only:
was = _FASTPATH_MEM_TO_MODE["2"]
else:
was = _FASTPATH_MEM_TO_MODE["1"]
if mode is not was:
return f"Unable to set VLAN membership for VLAN ( {vid} )"
return None
[docs]
def render_membership(
state: VirtualSwitchState,
spec: HttpModelSpec,
form: dict[str, str],
*,
err_msg: str = "",
) -> str:
"""Render the VLAN Membership page for the VLAN ``form`` selected."""
page = _page_sim(state)
vid = _shown_vlan(state, form)
codes = _codes(state, vid)
hidden_mem = ",".join(codes) + ("," if page.trailing_comma else "")
tagged, untagged = _iface_lists(state, vid)
vsim = state.vlans.get(vid)
options = "".join(
f'<OPTION class="selectfield" value="{v}"'
f"{' SELECTED' if v == vid else ''}>{v}\n"
for v in sorted(state.vlans)
)
csrf = (
'<INPUT TYPE="hidden" NAME="CSRFToken" ID="CSRFToken" VALUE="virtualcsrf">\n'
if page.csrf
else ""
)
grid = _grid_gif(state, codes) if page.grid == "gif" else _grid_png(state, codes)
action = spec.vlan_membership_post_path or ""
return (
'<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN">\n'
"<HTML><HEAD><TITLE>VLAN Configuration</TITLE></HEAD>\n"
"<body onLoad='check_error()'>\n"
f'<FORM method="post" ACTION="{action}">\n'
'<table class="tableStyle" id="tbl1">\n<tr><td>VLAN ID</td><td>\n'
f'<SELECT name="vlanId" class="select" onChange="screen_refresh()">\n'
f"{options}</SELECT></td>\n"
'<td>Group Operation</td><td><SELECT name="select" class="select" '
'id="groupOpera" onChange="imgtag(\'groupOpera\');enableImage()">\n'
'<OPTION value="UntagAll" selected="selected" >Untag All</option>\n'
'<OPTION value="TagAll" >Tag All</option>\n'
'<OPTION value="RemoveAll" >Remove All</option>\n'
"</SELECT></td></tr>\n"
f'<tr><td>VLAN Name</td><td><INPUT name="vlan_name" type="text" '
f'class="inputDisabled" READONLY VALUE="{vsim.name if vsim else ""}">'
"</td></tr>\n"
f'<tr><td>VLAN Type</td><td><INPUT name="vlan_type" type="text" '
f'class="inputDisabled" READONLY '
f'VALUE="{"Default" if vid == 1 else "Static"}"></td></tr>\n'
f"</table>\n{grid}"
f'<INPUT TYPE="hidden" NAME="err_flag" VALUE="{"1" if err_msg else "0"}">\n'
f'<INPUT TYPE="hidden" NAME="err_msg" VALUE="{err_msg}">\n'
f'<INPUT TYPE="hidden" NAME="hiddenTagged" id="hiddenTagged" '
f'VALUE="{tagged}">\n'
f'<INPUT TYPE="hidden" NAME="hiddenUnTagged" id="hiddenUnTagged" '
f'VALUE="{untagged}">\n'
f'<INPUT TYPE="hidden" NAME="hiddenMem" id="hiddenMem" '
f'VALUE="{hidden_mem}">\n'
f'<INPUT TYPE="hidden" id="submt" NAME="submt" VALUE="0">\n'
f'<INPUT TYPE="hidden" id="cncel" NAME="cncel" VALUE="">\n'
f'<INPUT TYPE="hidden" id="port_id" NAME="port_id" VALUE="0">\n'
f'<INPUT TYPE="hidden" id="click_id" NAME="click_id" VALUE="0">\n'
f'<INPUT TYPE="hidden" NAME="selectedPorts" id="selectedPorts" VALUE="">\n'
f'<INPUT TYPE="hidden" NAME="ignoreMouseUp" id="ignoreMouseUp" VALUE="">\n'
f'<INPUT TYPE="hidden" NAME="mouseX" id="mouseX" VALUE="">\n'
f'<INPUT TYPE="hidden" NAME="mouseY" id="mouseY" VALUE="">\n'
f'<INPUT TYPE="hidden" NAME="processedClick" id="processedClick" VALUE="">\n'
f"{csrf}</FORM></body></HTML>\n"
)
[docs]
def apply_membership(state: VirtualSwitchState, form: dict[str, str]) -> None:
"""Apply a membership POST -- but ONLY when ``submt`` is the apply flag.
``submt=0`` is the VLAN ``<select>``'s own re-render POST and must not
mutate anything: the reader relies on that to page through VLANs, and a mock
that wrote on every POST would let a reader silently rewrite every VLAN it
read. Confirmed on hardware, by reading a VLAN twice and diffing the two
responses (byte-identical) on all four switches.
A port set Excluded loses its CURRENT membership too; a port set
tagged/untagged becomes a current member. ``configured_only`` is cleared for
a port the caller explicitly set, because the caller has now stated that
port's participation outright.
"""
from ..registry import get_model
if form.get("submt") != _APPLY or refusal(state, form) is not None:
# A refused apply changes NOTHING on real hardware (the page re-renders
# the unchanged VLAN alongside err_flag=1), so the mock must not mutate
# either -- otherwise a writer that ignored err_flag would still "pass".
return
page = state.vlan_membership_page
if page is None: # pragma: no cover - the face 404s such a model first
return
vid = _shown_vlan(state, form)
vsim = state.vlans.get(vid)
if vsim is None:
return
codes = [c for c in form.get("hiddenMem", "").split(",") if c != ""]
port_count = get_model(state.model_key).port_count
for port in _physical_ports(state):
code = codes[port - 1] if port - 1 < len(codes) else None
mode = _FASTPATH_MEM_TO_MODE.get(code or "")
if mode is None:
continue
vsim.configured_only.discard(port)
if mode.value == "excluded":
# Only participation is dropped. The device's UNTAGGED bitmap is a
# separate axis and keeps its bit for a non-member port: real
# firmware's ``show vlan`` prints ``Tagging: Untagged`` on every
# excluded port, and the real GSM7252PS SNMP walk lists untagged
# ports that are not egress members at all (see _GSM7252PS_VLANS).
# Clearing it here would make the mock destroy state the web form
# cannot even express.
vsim.member.discard(port)
else:
vsim.member.add(port)
if mode.value == "untagged":
vsim.untagged.add(port)
else:
vsim.untagged.discard(port)
# LAG slots: the mock models LAGs only as ifIndexes above the physical port
# count, so an apply that clears a LAG slot removes that LAG from the VLAN.
lags = [p for p in sorted(vsim.configured) if p > port_count]
base = _lag_base(state)
for i, lag in enumerate(lags):
slot = base + i
code = codes[slot] if slot < len(codes) else None
mode = _FASTPATH_MEM_TO_MODE.get(code or "")
if mode is not None and mode.value == "excluded":
vsim.member.discard(lag)
vsim.configured_only.discard(lag)