Source code for netgear_switch.registry

"""Declarative registry of known Netgear switch models."""

from __future__ import annotations

import enum
from dataclasses import dataclass
from types import MappingProxyType
from typing import TYPE_CHECKING

from .errors import UnknownModelError

if TYPE_CHECKING:
    from collections.abc import Mapping

_FM = "1.3.6.1.4.1.4526.10"  # Fully Managed vendor subtree (M4300, GSM7252PS)
_SMP = "1.3.6.1.4.1.4526.11"  # Smart Managed Pro vendor subtree (S3300/GSM7228PS)


[docs] class Backend(enum.Enum): SNMP = "snmp" NSDP = "nsdp" HTTP = "http" # FASTPATH command-line interface, reachable over three transports. SSH and # TELNET are network backends registered on the FASTPATH models below; # CONSOLE is the same CLI over a physical serial line (a transport option, # not a network-reachable backend, so it is not registered on any model). SSH = "ssh" TELNET = "telnet" CONSOLE = "console"
[docs] class SwitchClass(enum.Enum): FULLY_MANAGED = "fully_managed" SMART_MANAGED_PRO = "smart_managed_pro" PLUS = "plus"
[docs] @dataclass(frozen=True) class SwitchModel: key: str display_name: str switch_class: SwitchClass port_count: int poe_port_count: int backends: frozenset[Backend] snmp_vendor_base: str | None # True (the default) for every model with a real device capture or other # hardware-validated prior art backing its fields. False marks a model # registered from spec sheets/product briefs alone, with NO capture -- # its port/PoE counts and (for SNMP models) vendor OID family are a # best-effort guess, and vendor-specific reads (get_sensors, vendor PoE # power, etc.) are UNVERIFIED-pending-capture even though the # model-agnostic standard-MIB/CGI reads should still work. See the # UNVERIFIED-pending-capture entries below (m7300, xs748t) for the honesty # rationale; do NOT flip this to True without a real capture. (gs728tpp was # one of these until a real SNMP capture resolved its OID family -- see its # entry.) verified: bool = True # How this model's SNMP agent accepts a VLAN port-membership WRITE. # # "qbridge" (the default): the standard Q-BRIDGE # dot1qVlanStaticEgressPorts/UntaggedPorts PortLists are read-WRITE, so a # membership change is a read-modify-write of those bitmaps. VERIFIED live on # the GSM7252PS (10.1.5.22) and the S3300-52X (10.1.5.11) -- and it is the # ONLY membership mechanism either of them publishes: a walk of the vendor # switchport table 1.3.6.1.4.1.4526.10.1.2.8.37 returned ZERO rows on both # (2026-07-30, community "public"), versus 1520 rows on the m4300-24x and 1440 # on the m4300-16x. So this is measured per-model reality on all four SNMP # switches, not "qbridge worked so we stopped looking" (principle 3). # # "fastpath_switchport": on FASTPATH 12.x (VERIFIED live on BOTH M4300 SKUs) # membership is owned by the per-port SWITCHPORT MODE, so writes go to the # vendor switchport table (see oids.FASTPATH_SWITCHPORT_* and # snmp_write._plan_switchport_membership, which documents the full derivation # and the live evidence). dot1qVlanStaticEgressPorts is writable ONLY while no # interface on the switch is in access mode -- and since an UNTAGGED write is # expressed as access mode, the qbridge dialect cannot be used here at all. # dot1qVlanStaticUntaggedPorts is worse than read-only: a SET returns noError # and is then silently discarded (proved on the -24X). dot1qVlanStaticRowStatus # := notInService also commitFails, so there is no RFC-2674 # suspend/modify/activate route either. snmp_vlan_write: str = "qbridge" # Write the egress and untagged PortLists in SEPARATE PDUs, egress first, # instead of one atomic multi-varbind SET. # # VERIFIED live on the S3300-52X-PoE+ (10.1.5.11, Smart firmware): setting a # port's egress bit has a side effect -- the firmware makes that port an # UNTAGGED member -- and when both columns travel in ONE PDU that side effect # wins, so a TAGGED request silently comes back untagged: # one PDU : egress=[1] untagged=[1] <- untagged intent lost # two PDUs : egress=[1] untagged=[] <- correct, CLI confirms "Tagged" # The GSM7252PS applies a single combined PDU correctly, so this stays opt-in # per model rather than changing that verified path. snmp_vlan_split_membership_writes: bool = False # Can this model's SNMP agent CREATE a VLAN (a dot1qVlanStaticTable row)? # # False only where hardware has proved it cannot, with the device's own # refusal captured. MEASURED on the GS728TPP # (sw-netgear-gs728tpp.monarto.mithis.com / 10.2.5.10, firmware 6.0.1.30, # 2026-08-03): EVERY documented RowStatus creation mechanism is answered # inconsistentValue, naming .1.3.6.1.2.1.17.7.1.4.3.1.5.<vlan> -- # # createAndGo(4) alone inconsistentValue # createAndGo(4) + dot1qVlanStaticName in ONE PDU inconsistentValue # createAndWait(5), then name, then active(1) inconsistentValue (each) # dot1qVlanStaticName alone (implicit row creation) inconsistentValue # createAndGo(4) + name + empty 126-byte egress PortList inconsistentValue # # This is NOT a read-only table and NOT a rejected VLAN id. On the same # switch in the same session: an EXISTING row's membership was rewritten # (VLAN 90 / port 17, tagged -> excluded -> tagged, verified and restored), # dot1qPvid was written and read back, destroy(6) removed a VLAN, and the # web UI created VLAN 4001 without complaint. Row creation specifically is # unimplemented in this agent -- so create_vlan here is an HTTP operation, # which is exactly the reason the library keeps both backends. snmp_can_create_vlan: bool = True @property def has_mac_table(self) -> bool: # MAC/FDB table is only reachable via SNMP (managed switches). return Backend.SNMP in self.backends
def _model( key: str, display_name: str, switch_class: SwitchClass, port_count: int, poe_port_count: int, backends: set[Backend], snmp_vendor_base: str | None, *, verified: bool = True, snmp_vlan_write: str = "qbridge", snmp_vlan_split_membership_writes: bool = False, snmp_can_create_vlan: bool = True, ) -> SwitchModel: return SwitchModel( key=key, display_name=display_name, switch_class=switch_class, port_count=port_count, poe_port_count=poe_port_count, backends=frozenset(backends), snmp_vendor_base=snmp_vendor_base, verified=verified, snmp_vlan_write=snmp_vlan_write, snmp_vlan_split_membership_writes=snmp_vlan_split_membership_writes, snmp_can_create_vlan=snmp_can_create_vlan, ) _MODELS: dict[str, SwitchModel] = { m.key: m for m in ( _model( "m4300-24x", "M4300-24X (XSM4324CS)", SwitchClass.FULLY_MANAGED, # port_count=28 is a nominal upper bound; the live XSM4324CS reports # only 24 physical ports (1/0/1..24; SNMP/CLI capture 2026-07-29). # Left at 28 because it also sizes the verified HTTP VLAN-membership # bitmap and SNMP/NSDP port bitmaps -- changing it risks regressing # those. CLI get_stats iterates the switch's ACTUAL physical ports # (see cli_read.CliReader.get_stats), so it does not depend on this. 28, 0, {Backend.SNMP, Backend.HTTP, Backend.SSH, Backend.TELNET}, _FM, # VERIFIED live @10.1.5.13 (FASTPATH 12.0.13.8): the Q-BRIDGE static # PortLists are read-only mirrors here; membership writes must go # through the vendor switchport table. See SwitchModel.snmp_vlan_write. snmp_vlan_write="fastpath_switchport", ), _model( "m4300-16x", "M4300-16X (XSM4316)", SwitchClass.FULLY_MANAGED, 16, 16, {Backend.SNMP, Backend.HTTP, Backend.SSH, Backend.TELNET}, _FM, # VERIFIED live @10.1.5.20 (FASTPATH 12.0.19.15) on 2026-07-30 -- this # was previously only INFERRED from the -24X, and the inference needed # checking because this firmware DOES accept some Q-BRIDGE egress # writes. A deterministic A/B/A on port 1/0/1, issuing byte-identical # writes to a throwaway VLAN while flipping only that port's mode, # settled it: # general -> noError access -> commitFailed # general -> noError trunk -> noError # access -> commitFailed general -> noError # i.e. dot1qVlanStaticEgressPorts is writable only while NO interface # on the switch is in access mode (switch-wide, not per-VLAN). Since # an UNTAGGED membership write is expressed AS access mode, the qbridge # dialect would disable itself on first use -- so this SKU really does # belong on the switchport dialect. The same one rule also explains why # the -24X (21 of 24 ports access-mode) rejects the write in every # port mode. The switchport columns, by contrast, applied cleanly on # BOTH SKUs in every mode. snmp_vlan_write="fastpath_switchport", ), # HTTP added 2026-07-23: the XE FASTPATH web UI (login live-validated # on 10.1.5.22; read pages grounded in tests/fixtures/http/ # gsm7252ps_*.html) covers EVERY read op this model supports, sensors # and mgmt-IP included. HTTP only joins read dispatch once the spec's # reads_verified flips after the live cross-verify -- see # protocols/http/endpoints.py's _GSM7252PS. _model( "gsm7252ps", "GSM7252PS", SwitchClass.FULLY_MANAGED, 52, 48, {Backend.SNMP, Backend.HTTP, Backend.SSH, Backend.TELNET}, _FM, ), _model( "gsm7228ps", "GSM7228PS (S3300)", SwitchClass.SMART_MANAGED_PRO, 52, 48, # TELNET (not SSH): the S3300-52X's FASTPATH CLI is reachable over # telnet on the NON-STANDARD port 60000 (not 23) -- live-verified # 2026-07-30 on 10.1.5.11 (login admin+password, prompt # "(manage-sw-netgear-s3300-1) >"), with a full read sweep captured # into tests/fixtures/cli/gsm7228ps_*.txt. SSH is genuinely ABSENT: # the switch runs no ssh listener on any port (its SNMP tcpConnTable # shows only 80/443/60000). So the CLI backend is TELNET only; the # telnet transport dials CliModelSpec.telnet_port=60000. {Backend.SNMP, Backend.HTTP, Backend.TELNET}, _SMP, # VERIFIED 2026-07-30 against real hardware: the S3300-52X-PoE+ # (sw-netgear-s3300-1 @ 10.1.5.11, sysObjectID 4526.100.10.19). The # live capture (tests/fixtures/captures/gsm7228ps.json) CONFIRMED # the _SMP (4526.11) vendor family is correct here -- unlike # gs728tpp (which had zero 4526 OIDs), this switch's fan/temp/PoE # vendor data really does live under 4526.11.43, and all 9 read # ops cross-verified SNMP<->mock. Its sysDescr "S3300-52X-PoE+" is # auto-detected via SYSOBJECTID_MODELS (the OID map), since the # text is deliberately unmatchable (same shape as the unregistered # S3300-28X). Registered key is gsm7228ps; "s3300" is an alias # (see MODEL_ALIASES). NOTE 4526.100.10.19 is the product-ID OID, # distinct from the 4526.11 vendor DATA subtree. # # VERIFIED live 2026-07-30: this Smart firmware auto-untags a port # when its egress bit is set, and that side effect beats an untagged # varbind carried in the SAME PDU -- so the two columns must be # written in separate PDUs, egress first, or every TAGGED request # silently lands as UNTAGGED. snmp_vlan_split_membership_writes=True, ), _model( "gs110emx", "GS110EMX", SwitchClass.PLUS, 10, 0, {Backend.NSDP, Backend.HTTP}, None, ), _model( "gs305ep", "GS305EP", SwitchClass.PLUS, 5, 4, {Backend.NSDP, Backend.HTTP}, None, ), # --- UNVERIFIED-pending-capture below: no device capture exists for # these two models (gdoc2netcfg fleet models with no prior-art # fixture). Registered from spec sheets/product briefs only so # gdoc2netcfg can construct a SyncSwitch for them; see each entry's # comment for what specifically is a guess. The model-agnostic # standard-MIB SNMP reads (ports/vlans/lldp/PoE admin/stats/mgmt-IP) # should work regardless of the vendor OID family guess below, but # get_sensors() and vendor PoE-power readings are UNVERIFIED until a # real capture confirms the 4526.10 vs 4526.11 subtree. Do NOT treat # either of these as a source of confirmed behaviour -- confirm via # hardware verification before relying on anything beyond the standard # MIBs. (gs728tpp, once in this group, is now verified: a real SNMP # capture resolved its OID family to "none, standard MIBs only".) _model( "m7300", # M7300-24XF (24x SFP+, 0 PoE) picked as the assumed/documented # variant -- the M7300 family also ships non-XF and other port # counts; which exact SKU gdoc2netcfg's fleet actually runs is # UNVERIFIED. Same FASTPATH fully-managed lineage as M4300, so # the 4526.10 ("_FM") vendor subtree is the best spec-guess, but # that family assignment is itself UNVERIFIED-pending-capture. "M7300-24XF", SwitchClass.FULLY_MANAGED, 24, 0, {Backend.SNMP}, _FM, verified=False, ), _model( "xs748t", # XS748T: 48x 10G copper (+ SFP+ combo), non-PoE per the # documented base spec -- UNVERIFIED-pending-capture. HTTP is # plausible for a Smart Managed Pro switch but is deliberately # OMITTED here (not just unverified): see gsm7228ps for the # SNMP+HTTP shape once a login/read flow is actually captured. # Until then SNMP-only avoids implying a web-UI integration that # does not exist in this codebase. "XS748T", SwitchClass.SMART_MANAGED_PRO, 48, 0, {Backend.SNMP}, _SMP, verified=False, ), _model( "gs728tpp", # GS728TPP: 24x Gigabit PoE+ + 4x SFP combo = 28 total ports, # 24 PoE+ -- UNVERIFIED-pending-capture (port split assumed from # the product name's "28" port count and Gigabit PoE+ line # convention, not a capture). # # HTTP backend NOW IMPLEMENTED (was deliberately omitted). This # model's web UI uses a THIRD, distinct login scheme -- # LoginScheme.XML_API, the GoAhead ``wcd`` XML API: a GET / # redirect to a per-session path, then GET {path}/System.xml? # action=login&user=...&password=... (not a POST) yielding # <statusCode>0</statusCode> + a sessionID response header, with # userStatus/usernme/sessionID cookies set from that response. # Grounded in certbot-hook-netgear-switches/netgear-updater.py's # GS728TPPUpdater AND real captures of the live switch 10.2.5.10; # transport/http/client.py's login() now drives it and # protocols/http/endpoints.py::_GS728TPP carries the wcd read # queries (HtmlDialect.GOAHEAD_XML). The web reads are # reads_verified=True: every parse_goahead_* was run on a FRESH live # wcd fetch from 10.2.5.10 (via the ten64 jump host) on 2026-07-29 # and cross-checked against the switch's actual config -- 28 ports, # 24 PoE, real VLAN names/PVIDs/membership, MAC table, 4 real LLDP # neighbours, fan/PSU sensors, mgmt-IP 10.2.5.10. (Cross-checked vs # the switch's ground truth, not vs SNMP, since this model's SNMP # OID family is itself UNVERIFIED-pending-capture.) # # SNMP now RESOLVED by a real live capture (10.2.5.10, 2026-07-29 -- # tmp/gs728tpp_snmp_full.json): this agent implements ZERO Netgear # vendor OIDs -- a walk of 1.3.6.1.4.1.4526 answers noSuchObject # (sysObjectID 4526.100.4.27 is just an identifier value). It serves # EVERYTHING via standard MIBs, so snmp_vendor_base=None: per-port # PoE via RFC3621 pethPsePortTable, mgmt-IP via ipAddrTable, and the # fan/PSU sensor INVENTORY via ENTITY-MIB entPhysical (there is NO # live sensor value/status anywhere in SNMP on this model). The SNMP # reader's standard-MIB code paths (snmp_read.get_poe/get_sensors/ # get_mgmt_ip guard on oids.has_vendor_oids) cover all three. # verified=True: SNMP<->HTTP parity is cross-verified for ports/ # vlans/pvids/macs/lldp/poe(admin+detect)/mgmt-IP (see # tests/test_cross_backend_equivalence.py). Two honest per-backend # differences remain (not bugs): SNMP has no per-port PoE mW column # (power_mw None vs HTTP's live 0), and SNMP sensors are inventory- # only (no live health status the HTTP DiagnosticsUnitList reports). # get_stats over HTTP is honestly UnsupportedCapabilityError # (per-port stats are SNMP-only on this UI). "GS728TPP", SwitchClass.SMART_MANAGED_PRO, 28, 24, {Backend.SNMP, Backend.HTTP}, None, # Its SNMP agent cannot CREATE a VLAN -- measured, with the # device's own inconsistentValue for every documented RowStatus # mechanism; see the field's docstring. Everything else in the VLAN # surface (membership, PVID, destroy) works over SNMP, and creation # works over HTTP. snmp_can_create_vlan=False, ), _model( "gs105pe", # GS105PE: a real, DISTINCT SKU from gs305ep -- a 5-port Gigabit # "Smart Plus" switch (Gen-2 Broadcom BCM53125), per # ~/github/mithro/ai-shenanigans-for-netgear-smart-switches/ # gs105pe.md. Backends {NSDP, HTTP} (no SNMP -- Plus switches # never expose SNMP): HTTP login uses the MERGE_HASH_CGI scheme, # identical to gs305ep, per netgear-smp-vlan (grounded prior art # -- see endpoints.py's HttpModelSpec for this model). That doc # also confirms a hard vendor limitation: this model exposes NO # MAC/FDB table over ANY interface (not NSDP, not the web UI -- # a confirmed Netgear firmware limitation, not merely unread). # # LIVE-VERIFIED 2026-07-21 against real GS105PE units (poe-micro2/3 # @ 10.1.5.29/.30): NSDP reports MODEL="GS105PE", port_count=5, # firmware V1.6.0.x. All NSDP reads (ports/stats/VLANs/PVIDs/mgmt + # full get_device) confirmed live -- see seed_gs105pe() and the # two real NSDP bugs this model exposed (parse_device MODEL # requirement + variable-width PORT_MIRRORING). # # PoE port count = 0, now CONFIRMED (not merely unverified): the # web UI's getPoePortStatus.cgi returns HTTP 404 on the real unit, # i.e. it exposes no PSE PoE-status page. The product's "PoE # pass-through" (it can be POWERED via PoE) is not a PSE claim, so # it sources power to no downstream ports -> 0. "GS105PE", SwitchClass.PLUS, 5, 0, {Backend.NSDP, Backend.HTTP}, None, verified=True, ), ) } MODELS: Mapping[str, SwitchModel] = MappingProxyType(_MODELS) # Alternate model-name keys that resolve to the same canonical SwitchModel via # get_model(). "s3300" <-> "gsm7228ps": the model registered under the canonical # key "gsm7228ps" is really the S3300-52X-PoE+ (its real firmware sysDescr and # marketing name are "S3300-52X"; GSM7228PS is the ProSAFE part-number family), # so both names must resolve to it -- inventories, the CLI's --model, and any # caller may use either. Aliases are deliberately NOT added to MODELS, which # stays a canonical one-key-per-model listing (what tests, the MCP model list # and CLI iterate); they are resolved only on lookup. MODEL_ALIASES: Mapping[str, str] = MappingProxyType( { "s3300": "gsm7228ps", } )
[docs] def get_model(key: str) -> SwitchModel: canonical = MODEL_ALIASES.get(key, key) try: return _MODELS[canonical] except KeyError: raise UnknownModelError(f"unknown switch model: {key!r}") from None