Source code for netgear_switch.protocols.cli.commands

"""Per-model FASTPATH CLI command specs (pure data).

The CLI equivalent of ``protocols/http/endpoints.py``. Each ``CliModelSpec``
records the ``show`` command each read op issues, the config-mode commands each
VLAN WRITE op issues (see ``cli_write.CliWriter``), the per-model physical
interface-name template every per-port command is addressed by, plus the
session-setup commands (``enable`` + disable output paging), and two honesty
flags:

* ``captured`` -- True only for a model with a REAL captured CLI transcript
  backing its parsers: ``gsm7252ps`` (see ``tests/fixtures/cli/gsm7252ps_*.txt``),
  ``m4300-24x`` (``tests/fixtures/cli/m4300_24x_*.txt``, captured live from
  10.1.5.13 on 2026-07-29), ``m4300-16x`` (``tests/fixtures/cli/m4300_16x_*.txt``,
  captured live from 10.1.5.20 on 2026-07-29) and ``gsm7228ps`` (the S3300-52X;
  ``tests/fixtures/cli/gsm7228ps_*.txt``, captured live from 10.1.5.11 on
  2026-07-30 over telnet on port 60000).
* ``reads_verified`` -- True for gsm7252ps (live CLI-vs-SNMP cross-verified on
  10.1.5.22), m4300-24x (live CLI-verified on 10.1.5.13, 2026-07-29), m4300-16x
  (live CLI-verified on 10.1.5.20, 2026-07-29: ports/PVIDs/VLANs/MACs/LLDP/
  sensors/stats/mgmt-IP AND PoE all correct) and gsm7228ps (live telnet CLI
  captured on 10.1.5.11, 2026-07-30, and cross-verified against that model's SNMP
  capture ``tests/fixtures/captures/gsm7228ps.json``).

FASTPATH's ``show`` grammar is nearly identical across the Fully Managed
(M4300/GSM7252PS) and Smart Managed Pro (GSM7228PS/S3300) lines, but the exact
command set varies by firmware image:

* the newer M4300 firmware (12.0.13.8) renamed two commands -- see
  ``_M4300_OVERRIDES``;
* the Smart-firmware S3300 (gsm7228ps) rejects ``show vlan brief`` ("Invalid
  input") but accepts the bare ``show vlan`` (like the M4300s) while KEEPING the
  older ``show network`` (unlike the M4300s' ``show ip management``) -- see
  ``_GSM7228PS``.

Physical-port naming also differs: the Fully Managed line prints ``1/0/N`` while
the Smart-firmware S3300 prints ``1/gN`` (1-48) and ``1/xgN`` (uplinks 49-52).
Both are resolved by ``protocols.cli.parse._phys_port``.

Transports: SSH is the default network CLI transport, but a model may carry a
non-standard telnet port via ``CliModelSpec.telnet_port`` (the S3300's telnet CLI
listens on 60000, not 23) for models that expose TELNET but not SSH.
"""

from __future__ import annotations

import ipaddress
from dataclasses import dataclass
from types import MappingProxyType
from typing import TYPE_CHECKING, TypedDict

from ...errors import UnsupportedCapabilityError
from ...models import syslog_severity_word
from ...registry import Backend

if TYPE_CHECKING:
    from collections.abc import Mapping

    from ...models import PortSpeed
    from ...registry import SwitchModel

# The three CLI transports all speak the same FASTPATH CLI; a model that has any
# of them uses this spec set.
CLI_BACKENDS = frozenset({Backend.SSH, Backend.TELNET, Backend.CONSOLE})


[docs] def address_kind(address: str) -> str: """The address-KIND token a ``logging host`` line carries: ipv4/ipv6/dns. The live line is ``logging host "10.1.5.1" ipv4 514 info`` -- the kind is an explicit argument, not something the firmware infers, so it has to be derived from the address. Anything that is not a literal IP is ``dns``, which is what the host table's own column is headed ("IP Address/Hostname"). """ try: return f"ipv{ipaddress.ip_address(address).version}" except ValueError: return "dns"
[docs] def fastpath_rate(mbps: int) -> str: """How FASTPATH spells a port rate in a ``speed`` command: 10000 -> "10G". The two spellings are not a guess: a live gsm7252ps offered ``10``, ``100`` and ``10G`` as the forced rates on 1/0/8 (2026-08-03), so sub-gigabit rates go as bare Mbit/s and gigabit multiples take the ``G`` suffix. Rates outside that measured set are still formatted by the same rule and SENT -- the device answers "% Invalid input" for one it does not have, which the writer raises verbatim (see ``CliModelSpec.port_speed_forced_cmd``). """ if mbps >= 1000 and mbps % 1000 == 0: return f"{mbps // 1000}G" return str(mbps)
[docs] @dataclass(frozen=True) class CliModelSpec: model_key: str captured: bool reads_verified: bool # True once this model's CLI WRITE path has been driven against the real # switch: VLAN create/delete, per-port membership (tagged/untagged/excluded), # PVID and port admin state applied and read back, then restored to a # byte-identical running-config. Done for all four CLI models on 2026-07-30 # (see the per-model notes below); PoE additionally on the three PoE SKUs. # NOT covered by this flag, and deliberately never live-run: set_mgmt_ip # (would drop the very session issuing it) and reboot -- both documented as # unverified in ``cli_write.CliWriter``. writes_verified: bool = True # TCP port the telnet CLI transport dials for this model. Standard telnet is # 23, but the S3300-52X (gsm7228ps) FASTPATH telnet CLI listens on 60000. # Only consulted when a model's TELNET transport is built (see # ``_dispatch.build_sync_cli_client``); the SSH/console transports ignore it. telnet_port: int = 23 # Session setup, run once after the shell opens. enable_cmd: str = "enable" paging_off_cmd: str = "terminal length 0" # Read-op commands. The two templated ones take a positional format arg. version_cmd: str = "show version" port_status_cmd: str = "show port all" vlan_brief_cmd: str = "show vlan brief" vlan_detail_cmd: str = "show vlan {vlan}" pvid_cmd: str = "show vlan port all" mac_table_cmd: str = "show mac-addr-table" lldp_cmd: str = "show lldp remote-device all" poe_cmd: str = "show poe port info all" environment_cmd: str = "show environment" network_cmd: str = "show network" interface_stats_cmd: str = "show interface ethernet {iface}" # Host name. `show hosts` and NOT `show running-config | include hostname`: # the two report DIFFERENT values, measured 2026-08-02. On m4300-16x # (10.1.5.20) `show hosts` gives "sw-netgear-m4300-16x-poe-s2" while # running-config gives "manage-sw-netgear-m4300-16x-poe-s2", and on # gsm7252ps (10.1.5.22) running-config carries no hostname line at all while # `show hosts` still reports one. `show hosts` is the one that agrees with # SNMP's sysName, so it is what keeps the two backends returning the same # answer for the same switch. hosts_cmd: str = "show hosts" # Local login accounts. Present on both FASTPATH images measured; the # ACCESS-MODE vocabulary differs between them (see parse.parse_users). users_cmd: str = "show users" # Management services. `show ip http` covers BOTH web servers; the inbound # telnet server is `show telnetcon` and NOT `show telnet`, which reports # the switch as an outbound telnet CLIENT (measured 2026-08-02). http_service_cmd: str = "show ip http" telnet_service_cmd: str = "show telnetcon" ssh_service_cmd: str = "show ip ssh" # Remote logging. `show syslog` does NOT exist -- all three FASTPATH # switches answered "% Invalid input detected" on 2026-08-02. logging_cmd: str = "show logging" logging_hosts_cmd: str = "show logging hosts" # Global-config directive. Quoted on the wire by the device's own # running-config output ('hostname "sw-netgear-m4300-24x"'). hostname_config_cmd: str = "hostname {name}" # Remote logging, in GLOBAL config mode. READ OFF each switch's own # `show running-config` on 2026-08-05 -- the device printing its own command # sequence back, which is how the exact form was learned without a single # write and without `?` (see the hazard at port_speed_*). All four models # emit these two lines character for character: # # logging host "10.1.5.1" ipv4 514 info # logging syslog # # so the address is QUOTED, the address-kind token is explicit, and the # severity travels as a WORD. `logging syslog` is the remote-logging enable. # # REMOVAL IS NOT A NEGATION. `no logging host <index>` was the obvious # inference and it is WRONG -- a live gsm7252ps (10.1.5.22, 2026-08-05) # answered "% Invalid input detected at '^' marker." to it, and to # `no logging host <address>` in every quoted/unquoted/typed spelling. The # device's own help settles it: # # (Config)# logging host ? # <hostaddress|hostname> Enter Logging Host IP Address or Hostname # reconfigure Logging Host Reconfiguration # remove Logging Host Removal # # so removal is a SUBCOMMAND, `logging host remove <index>`, taking the # 1-based Index from `show logging hosts`. Confirmed by executing it: the # throwaway row went away and the table returned byte-identical to prior. # # (That help probe is safe despite the #72 `?` hazard: bare `logging host` # is INCOMPLETE -- it requires an address -- so the trailing newline is # rejected rather than executed, exactly like `speed`.) # # `no logging syslog` remains inferred; only the positive form appears in # running-config. A wrong form is raised, never swallowed. logging_host_add_cmd: str = 'logging host "{address}" {kind} {port} {severity}' logging_host_remove_cmd: str = "logging host remove {index}" logging_syslog_cmd: str = "logging syslog" logging_no_syslog_cmd: str = "no logging syslog" # --- physical-interface naming ----------------------------------------- # How this model's firmware ADDRESSES one physical port in a command # ("show interface ethernet <iface>", "interface <iface>"). The Fully # Managed FASTPATH line uses "1/0/<n>"; the Smart-firmware S3300-52X # (gsm7228ps) uses "1/g<n>" for its 48 1G ports and "1/xg<n>" for the four # 10G uplinks 49-52 -- both live-confirmed in that model's own captured # transcripts (tests/fixtures/cli/gsm7228ps_port_all.txt lists "1/g1"..., # gsm7228ps_vlan_port_all.txt lists "1/xg49"...). Reading these names back # is already handled by ``protocols.cli.parse._phys_port``; this is the # WRITE direction (and the per-port read commands), which previously # hardcoded "1/0/<port>" and would therefore have addressed a nonexistent # interface on the S3300. iface_template: str = "1/0/{port}" # Set together on a model whose uplinks carry a DIFFERENT prefix than its # access ports (the S3300-52X). None/None means "one template for all # ports", which is true of every Fully Managed model. uplink_iface_template: str | None = None first_uplink_port: int | None = None # --- write (config-mode) commands -------------------------------------- # GROUNDED PER MODEL, not extrapolated from one SKU: the VLAN sequences were # driven BY HAND on an M4300-24X (FASTPATH 12.0.13.8, 10.1.5.13, 2026-07-30) # and every command below was then confirmed against each reachable switch's # OWN context-sensitive help ("<partial> ?", read-only -- the line is # abandoned, never submitted) on 2026-07-30: # gsm7252ps 10.1.5.22 (SSH, XE image) # m4300-24x 10.1.5.13 (12.0.13.8) # m4300-16x 10.1.5.20 (12.0.19.15) # gsm7228ps 10.1.5.11 (S3300-52X Smart image, telnet 60000) # The differences that came out of that sweep are recorded in the per-model # specs below -- they are exactly the kind of thing extrapolating from one # SKU gets wrong. vlan_database_cmd: str = "vlan database" vlan_create_cmd: str = "vlan {vlan}" vlan_name_cmd: str = "vlan name {vlan} {name}" vlan_delete_cmd: str = "no vlan {vlan}" configure_cmd: str = "configure" interface_cmd: str = "interface {iface}" # A port only HONOURS "vlan participation"/"vlan tagging" while it is in # general mode (proven live on the M4300: in access mode the commands are # accepted into running-config yet ``show vlan <id>`` keeps reporting # Exclude/Autodetect). None means this image has NO switchport-mode concept # at all and the participation commands act directly -- the case on the # gsm7252ps, whose "switchport mode ?" answers "% Unrecognized command" # (its "switchport ?" offers only private-group/protected). switchport_general_cmd: str | None = "switchport mode general" vlan_participation_cmd: str = "vlan participation {action} {vlan}" vlan_tagging_cmd: str = "vlan tagging {vlan}" vlan_no_tagging_cmd: str = "no vlan tagging {vlan}" vlan_pvid_cmd: str = "vlan pvid {vlan}" # Per-port description, in interface config mode. The single quotes are the # firmware's OWN form, not a shell habit: READ OFF a live GSM7252PS # (10.1.5.22, 2026-08-03) whose `show running-config` renders its 38 labelled # ports as `description 'eth0.rpi5-pmod'`. Clearing one is `no description`, # the standard FASTPATH negation. port_description_cmd: str = "description '{text}'" port_no_description_cmd: str = "no description" # `show port all` has NO description column, so a description write cannot # verify itself through get_ports. This per-port command is the one that # carries it -- live output from 10.1.5.22 (2026-08-03): # Interface....... 1/0/8 # ifIndex......... 8 # Description..... # MAC address..... E0:91:F5:0C:D6:DD port_description_show_cmd: str = "show port description {iface}" # Per-port speed/duplex, in interface config mode. TWO grammars that mean # OPPOSITE things, both PROVEN BY EXECUTION on gsm7252ps 10.1.5.22 port # 1/0/8 (link-down, undescribed, 2026-08-03) and restored afterwards: # # speed <rate> <full-duplex|half-duplex> FORCE, auto-negotiation off # speed auto AUTO-NEGOTIATE # # The forced rates each port offers are a property of its PHY, not of the # firmware -- 1/0/8 (1G copper) offered 10/100/10G while m4300-24x 1/0/15 # (10GBASE-T) offered 100/10G -- so NO per-model rate table is kept here. # An unsupported rate is SENT and the device's own "% Invalid input" is what # surfaces, through the writer's treat-any-output-as-failure rule; the # switch is a better authority on its own ports than a table we maintain. # # The ONE rate refused before sending is a forced 1000 (see # ``cli_write.CliWriter.set_port_speed``): 1000BASE-T makes auto-negotiation # mandatory, and the firmware encodes that by leaving 1000 out of the forced # grammar entirely while keeping it in ``speed auto [10] [100] [1000] [10G]``. port_speed_auto_cmd: str = "speed auto" port_speed_forced_cmd: str = "speed {rate} {duplex}-duplex" # IEEE 802.3x flow control, in interface config mode -- a BARE TOGGLE # (``flowcontrol ?`` answers ``<cr>`` only). PROVEN as a full round trip on # gsm7252ps 10.1.5.22 port 1/0/8, 2026-08-03, though not deliberately: a # context-help probe executed the bare command, adding ``flowcontrol`` to # that port's running-config and moving its Flow Mode column from Disable to # Enable; ``no flowcontrol`` removed the line and returned the column. Both # directions were confirmed by diffing ``show running-config interface # 1/0/8``, and the port was restored byte-identically. (That accident is why # ``?`` is never used as a read-only probe in this project any more.) port_flow_control_cmd: str = "flowcontrol" port_no_flow_control_cmd: str = "no flowcontrol" exit_cmd: str = "exit" # PoE, in interface config mode. Identical on every PoE-capable FASTPATH # image probed ("poe ?" -> <cr>/detection/high-power/power/priority/reset/ # timer). "poe reset" is the device's OWN atomic PoE re-arm -- preferred over # off-then-on because no crash window can leave a port powered down. poe_enable_cmd: str = "poe" poe_disable_cmd: str = "no poe" poe_reset_cmd: str = "poe reset" # Port admin state, in interface config mode (identical on all four). port_enable_cmd: str = "no shutdown" port_disable_cmd: str = "shutdown" # Management IP. TWO DIALECTS, and the split is real: # * older images (gsm7252ps, gsm7228ps) take ONE privileged-EXEC command, # "network parms <ip> <mask> [<gateway>]" -- the write counterpart of # their "show network" read; # * the M4300 12.0.x images REJECT "network parms" outright ("% # Unrecognized command", in both EXEC and Config mode) and instead use # global-config "ip management address <ip> <mask>" plus # "ip default-gateway <gw>" -- matching their "show ip management" read. # Exactly one of the two tuples is non-empty per model. mgmt_ip_exec_cmds: tuple[str, ...] = ( "network parms {address} {netmask} {gateway}", ) mgmt_ip_config_cmds: tuple[str, ...] = () # Reboot, privileged EXEC ("reload ?" -> <cr>/<unit> on every model). reload_cmd: str = "reload"
[docs] def vlan_detail(self, vlan: int) -> str: return self.vlan_detail_cmd.format(vlan=vlan)
[docs] def iface(self, port: int) -> str: """The interface NAME this firmware addresses physical ``port`` by.""" if ( self.uplink_iface_template is not None and self.first_uplink_port is not None and port >= self.first_uplink_port ): return self.uplink_iface_template.format(port=port) return self.iface_template.format(port=port)
[docs] def interface_stats(self, port: int) -> str: return self.interface_stats_cmd.format(iface=self.iface(port))
[docs] def vlan_create(self, vlan: int) -> str: return self.vlan_create_cmd.format(vlan=vlan)
[docs] def vlan_name(self, vlan: int, name: str) -> str: return self.vlan_name_cmd.format(vlan=vlan, name=name)
[docs] def vlan_delete(self, vlan: int) -> str: return self.vlan_delete_cmd.format(vlan=vlan)
[docs] def interface(self, port: int) -> str: return self.interface_cmd.format(iface=self.iface(port))
[docs] def vlan_participation(self, vlan: int, *, include: bool) -> str: return self.vlan_participation_cmd.format( action="include" if include else "exclude", vlan=vlan )
[docs] def vlan_tagging(self, vlan: int, *, tagged: bool) -> str: cmd = self.vlan_tagging_cmd if tagged else self.vlan_no_tagging_cmd return cmd.format(vlan=vlan)
[docs] def vlan_pvid(self, vlan: int) -> str: return self.vlan_pvid_cmd.format(vlan=vlan)
[docs] def port_description(self, text: str) -> str: """Set or clear a port's description (interface config mode).""" if not text: return self.port_no_description_cmd return self.port_description_cmd.format(text=text)
[docs] def port_description_show(self, port: int) -> str: """The per-port command that reports a description back.""" return self.port_description_show_cmd.format(iface=self.iface(port))
[docs] def port_speed(self, speed: PortSpeed) -> str: """The interface-config command that applies ``speed`` (see the fields).""" if speed.autonegotiate: return self.port_speed_auto_cmd assert speed.speed_mbps is not None # PortSpeed.__post_init__ guarantees it return self.port_speed_forced_cmd.format( rate=fastpath_rate(speed.speed_mbps), duplex="full" if speed.full_duplex else "half", )
[docs] def logging_host_add(self, address: str, port: int, severity: int) -> str: """The `logging host` line for one collector, as running-config shows it.""" return self.logging_host_add_cmd.format( address=address, kind=address_kind(address), port=port, severity=syslog_severity_word(severity), )
[docs] def logging_host_remove(self, index: int) -> str: """Remove the collector at ``index`` (1-based, per `show logging hosts`).""" return self.logging_host_remove_cmd.format(index=index)
[docs] def logging_syslog(self, *, enabled: bool) -> str: return self.logging_syslog_cmd if enabled else self.logging_no_syslog_cmd
[docs] def port_flow_control(self, *, enabled: bool) -> str: """Turn 802.3x flow control on or off (interface config mode).""" return self.port_flow_control_cmd if enabled else self.port_no_flow_control_cmd
[docs] def poe_admin(self, *, on: bool) -> str: return self.poe_enable_cmd if on else self.poe_disable_cmd
[docs] def port_admin(self, *, enabled: bool) -> str: return self.port_enable_cmd if enabled else self.port_disable_cmd
[docs] def mgmt_ip( self, address: str, netmask: str, gateway: str ) -> tuple[tuple[str, ...], tuple[str, ...]]: """``(exec_commands, config_commands)`` for a management-IP write. Exactly one of the two is non-empty, per this model's dialect (see the ``mgmt_ip_*`` fields). """ fmt = {"address": address, "netmask": netmask, "gateway": gateway} return ( tuple(c.format(**fmt) for c in self.mgmt_ip_exec_cmds), tuple(c.format(**fmt) for c in self.mgmt_ip_config_cmds), )
class _CliCmdOverrides(TypedDict, total=False): """The subset of ``CliModelSpec`` command fields a model may override (typed so ``**`` splatting into ``CliModelSpec`` cannot touch ``telnet_port`` or any other int field).""" vlan_brief_cmd: str network_cmd: str mgmt_ip_exec_cmds: tuple[str, ...] mgmt_ip_config_cmds: tuple[str, ...] # M4300 FASTPATH 12.0.13.8 renamed two read commands vs the older gsm7252ps # image (live-confirmed on 10.1.5.13): # "show vlan brief" -> "show vlan" ("show vlan brief" is Invalid input) # "show network" -> "show ip management" ("show network" deprecated) # The output formats are otherwise the same fixed-width tables/dotted-leader # scalars, so the existing parse_vlan_brief/parse_mgmt_ip parsers apply unchanged. # The management-IP WRITE moved with the read: this image has no "network parms" # at all (verified 2026-07-30 -- "% Unrecognized command" in both privileged # EXEC and global config on 10.1.5.20), and uses the global-config pair # "ip management address <ip> <mask>" + "ip default-gateway <gw>" instead # ("ip management address 10.1.5.20 ?" -> </prefix-length>|<subnet-mask>; # "ip default-gateway ?" -> <gateway-addr>). _M4300_OVERRIDES: _CliCmdOverrides = { "vlan_brief_cmd": "show vlan", "network_cmd": "show ip management", "mgmt_ip_exec_cmds": (), "mgmt_ip_config_cmds": ( "ip management address {address} {netmask}", "ip default-gateway {gateway}", ), } # gsm7252ps: real captured transcript (SSH, 10.1.5.22). # reads_verified=True: live CLI<->SNMP cross-verified 2026-07-25 on 10.1.5.22. # switchport_general_cmd=None: this XE image has NO switchport-mode concept -- # probed live 2026-07-30 on 10.1.5.22, "switchport mode ?" answers # "% Unrecognized command" and "switchport ?" offers only # private-group/protected. Sending "switchport mode general" here would be # REJECTED (non-empty output = failure), so the writer must not send it; its # "vlan participation"/"vlan tagging" commands act on the port directly. # writes_verified=True: full CLI write run on 10.1.5.22 2026-07-30 against # link-down, undescribed port 1/0/6 -- create VLAN 4001, tag/untag/exclude the # port, set + restore its PVID, shutdown + no shutdown, delete the VLAN; the # port's running-config, PVID and (tagged) VLAN 1+5 membership all came back # byte-identical, with NO switchport-mode command issued. _GSM7252PS = CliModelSpec( model_key="gsm7252ps", captured=True, reads_verified=True, switchport_general_cmd=None, ) # m4300-24x: real captured transcript (10.1.5.13, tests/fixtures/cli/m4300_24x_*). # reads_verified=True: live CLI-verified 2026-07-29 on 10.1.5.13 (M4300-24X, # FASTPATH 12.0.13.8) -- ports/pvids/vlans/macs/lldp/sensors/stats/mgmt-IP all # correct with the two command overrides above. # writes_verified=True: full CLI write run on 10.1.5.13 2026-07-30 against # link-down port 1/0/8 (description 'empty') -- VLAN 4001 created, port tagged # then untagged then excluded, PVID set and restored, shutdown/no shutdown, VLAN # deleted; running-config, PVID and VLAN 1 untagged membership byte-identical # afterwards. PoE is absent on this SKU (see cli_write._require_poe). _M4300_24X = CliModelSpec( model_key="m4300-24x", captured=True, reads_verified=True, **_M4300_OVERRIDES ) # m4300-16x: real captured transcript (tests/fixtures/cli/m4300_16x_*). # reads_verified=True: live CLI-verified 2026-07-29 on 10.1.5.20 (M4300-16X-PoE, # FASTPATH 12.0.19.15) -- ports/pvids/vlans/macs/lldp/sensors/stats/mgmt-IP AND # `show poe port info all` (16 PoE ports; the M4300 image omits the Temperature # column, handled by the header-name column lookup in parse_poe) all correct. # writes_verified=True: full CLI write run on 10.1.5.20 2026-07-30 against # link-down port 1/0/1 -- VLAN/membership/PVID/port-admin as above PLUS PoE # ("no poe" then "poe", both verified) and cycle_poe honestly failing on a port # with no powered device. This run is also where two hardware facts came from: # the PoE status column LAGS an admin re-enable by a read, and "no switchport # mode" (not "switchport mode access") is what restores a port's prior # behaviour. Restored to a byte-identical running-config. _M4300_16X = CliModelSpec( model_key="m4300-16x", captured=True, reads_verified=True, **_M4300_OVERRIDES ) # gsm7228ps (the S3300-52X): real captured telnet transcript (10.1.5.11, port # 60000, tests/fixtures/cli/gsm7228ps_*.txt). reads_verified=True: live telnet # CLI captured 2026-07-30 and cross-verified against the model's SNMP capture. # This SKU's Smart firmware needs the bare "show vlan" (the M4300-style override; # "show vlan brief" is "Invalid input" here) but keeps the older "show network" # (NOT the M4300's "show ip management"), so it takes exactly one of the two # _M4300_OVERRIDES. Its telnet CLI listens on 60000, not 23 (SSH is genuinely # absent -- no listener on any port; the registry declares TELNET but not SSH). # Unlike the gsm7252ps (the other pre-12.0 image here) this Smart firmware DOES # have switchport modes -- "switchport mode ?" -> access|general|trunk, probed # live 2026-07-30 on 10.1.5.11 -- so it keeps the default general-mode step. Its # management-IP write is the older privileged-EXEC "network parms <ip> <mask> # [<gateway>]" (probed: "network parms 10.1.5.22 255.255.255.0 ?" -> <cr>| # <gateway>), i.e. the default, matching its "show network" read. # Its ports are ALSO named differently: "1/g1".."1/g48" for the 48 1G ports and # "1/xg49".."1/xg52" for the four 10G uplinks (both shapes appear verbatim in # this model's own captured transcripts), so every per-port command -- the # read-side "show interface ethernet <iface>" as well as the write-side # "interface <iface>" -- must be addressed that way, NOT as "1/0/<n>". # writes_verified=True: full CLI write run on 10.1.5.11 2026-07-30 over telnet # 60000 against link-down port 1/g1 -- VLAN 4001 create/tag/untag/exclude/delete, # PVID 21 -> 4001 -> 21, shutdown/no shutdown, PoE off/on, cycle_poe honestly # failing with no PD; the port's running-config (including its "vlan pvid 21 / # vlan participation auto 1 / include 21 / green-mode" lines) came back # byte-identical. This is the run that proves the "1/g<n>" write addressing. _GSM7228PS = CliModelSpec( model_key="gsm7228ps", captured=True, reads_verified=True, telnet_port=60000, vlan_brief_cmd="show vlan", network_cmd="show network", iface_template="1/g{port}", uplink_iface_template="1/xg{port}", first_uplink_port=49, ) _SPECS: dict[str, CliModelSpec] = { s.model_key: s for s in (_GSM7252PS, _M4300_24X, _M4300_16X, _GSM7228PS) } CLI_SPECS: Mapping[str, CliModelSpec] = MappingProxyType(_SPECS)
[docs] @dataclass(frozen=True) class ScpCertProfile: """Per-model FASTPATH SSL-cert-over-SCP deploy profile (pure data). A TRANSCRIPTION of the working certbot-hook ``MODEL_PROFILES`` (see ``tmp/certbot_hook_prior_art.py`` -- grounded prior art). Only the Fully Managed FASTPATH models that take a certificate over ``copy scp://`` carry one; the Smart Managed Pro line (gsm7228ps/S3300) uses an HTTP multipart upload instead and is deliberately absent here. * ``crypto`` -- ``"modern"`` or ``"legacy"``: which SSH key-exchange / host-key algorithm set the switch's sshd needs. The library's SSH transport already re-inserts the legacy algorithms this old firmware requires (see ``transport/cli/ssh.py``); this flag is carried for the CALLER (e.g. the certbot hook) that stages the PEM and may open its own SCP source. * ``writemem_stuff`` -- True when ``write memory``'s confirm has a tiny timeout, so the ``y`` must be pre-stuffed in one write (GSM7252PS); False for the M4300s, which take a normal read-then-answer confirm. * ``verify_port`` -- the HTTPS port a post-deploy fingerprint check connects to. NOT used by the deploy itself (the library only SENDS the copy commands; verification is the caller's job), carried for parity with the prior art so a caller need not re-derive it. """ model_key: str crypto: str writemem_stuff: bool verify_port: int
# GROUNDED: transcribed from certbot-hook MODEL_PROFILES. NOT live-verified in # this library (a real SCP upload is a production write needing a staging SCP # server) -- see ``cli_write.deploy_certificate_scp``. _SCP_CERT_PROFILES: dict[str, ScpCertProfile] = { p.model_key: p for p in ( ScpCertProfile( "m4300-24x", crypto="modern", writemem_stuff=False, verify_port=443 ), ScpCertProfile( "m4300-16x", crypto="modern", writemem_stuff=False, verify_port=49152 ), ScpCertProfile( "gsm7252ps", crypto="legacy", writemem_stuff=True, verify_port=443 ), ) } SCP_CERT_PROFILES: Mapping[str, ScpCertProfile] = MappingProxyType(_SCP_CERT_PROFILES)
[docs] def scp_cert_profile(model: SwitchModel) -> ScpCertProfile: """Return the FASTPATH SCP cert-deploy profile for ``model``. Raises ``UnsupportedCapabilityError`` for any model with no ``copy scp://`` cert-deploy path -- i.e. every non-FASTPATH model, AND FASTPATH models whose cert upload uses a different mechanism (gsm7228ps: HTTP multipart). This is the gate the facade's ``upload_certificate_scp`` dispatches on. """ if not (CLI_BACKENDS & model.backends): raise UnsupportedCapabilityError( f"model {model.key!r} has no CLI backend for an SCP cert deploy" ) try: return _SCP_CERT_PROFILES[model.key] except KeyError: raise UnsupportedCapabilityError( f"model {model.key!r} has no known copy-scp SSL-certificate deploy profile" ) from None
[docs] def cli_spec(model: SwitchModel) -> CliModelSpec: """Return the CLI command spec for ``model`` or raise if it has no CLI backend.""" if not (CLI_BACKENDS & model.backends): raise UnsupportedCapabilityError(f"model {model.key!r} has no CLI backend") try: return _SPECS[model.key] except KeyError: raise UnsupportedCapabilityError( f"model {model.key!r} has a CLI backend but no command spec" ) from None