Source code for netgear_switch.protocols.nsdp.protocol

"""NSDP wire codec: 32-byte header, TLV entries, and packet encode/decode.

Lifted (field-for-field) from ``gdoc2netcfg/src/nsdp/protocol.py``, with the
header's old opaque 4-byte reserved blob split once hardware showed what is in
it. The header is ``struct`` layout ``>BB H H 2s 6s 6s I 4s 4s`` (32 bytes):
version (always 0x01), operation, result, error-attr(2), reserved(2), client
MAC(6), server MAC(6), sequence(4), signature ``b"NSDP"`` at offset 0x18,
reserved(4). Each TLV is ``>HH`` (tag, length) followed by ``length`` value
bytes; a packet ends with the ``0xFFFF 0x0000`` end-of-marker.

This module is a pure, zero-dependency codec: no sockets, no I/O. The write
path (``Op.WRITE_REQUEST``/``Op.WRITE_RESPONSE`` and ``NSDPPacket.add_tlv``
with a non-empty value) is new relative to the read-only prior-art client,
but uses the exact same wire layout.
"""

from __future__ import annotations

import struct
from dataclasses import dataclass, field
from enum import IntEnum

NSDP_SIGNATURE = b"NSDP"
HEADER_SIZE = 32
# The sequence number is a FULL 4-byte field (">...I..."), not a 2-byte value
# preceded by 2 reserved bytes. Cross-checking the mock against ngadmin
# (herveboisse/ngadmin, whose `struct nsdp_header` is the authoritative NSDP
# layout) surfaced this: its `unsigned int seqnum` spans the two bytes this
# format previously treated as reserved. Observationally identical for the
# small sequence numbers real clients/switches use (the high 2 bytes are 0),
# but a seqnum > 0xFFFF would have been silently truncated on decode and
# mis-echoed by the mock.
# Header bytes 4-5 are NOT reserved: they carry the TLV TAG that caused the
# error reported in bytes 2-3. Two independent investigations of this repo's
# GS110EMX fleet landed on the same field (one from an exhaustive tag sweep, one
# from cracking the v2 write auth), and ngadmin's `struct nsdp_header` names it
# (`unsigned short attr; /* attribute code which caused error */`). Real
# hardware (10.1.5.25, fw 1.0.2.8, 2026-07-29/30) fills it in: a read of an
# unanswerable tag comes back error=3 with this field set to that very tag; a
# write rejected for sending v1 auth comes back with 0x000A (ATTR_PASSWORD);
# a write rejected for a bad v2 token blames the packet's leading TLV. Splitting
# the old opaque ``4s`` reserved field into ``H 2s`` is what lets ``NsdpError``
# name the thing that failed instead of just "the request failed" (principle 1).
HEADER_FORMAT = ">BB H H 2s 6s 6s I 4s 4s"
END_MARKER = struct.pack(">HH", 0xFFFF, 0x0000)  # b"\xff\xff\x00\x00"

# Header error codes (byte 2). Named by ngadmin's protocol.h; codes 13/14 are
# NOT in that list and were MEASURED on a GS110EMX fw 1.0.2.8 -- see
# ``ERROR_NAMES`` and ``client.check_result``.
ERROR_NONE = 0
ERROR_READONLY = 3  # "this attribute cannot be read" on a read request
ERROR_WRITEONLY = 4
ERROR_INVALID_VALUE = 5
ERROR_DENIED = 7  # v1 (older-firmware) password denial
# LIVE-MEASURED on real GS110EMX units (10.1.5.25/.26/.27, fw 1.0.2.8,
# 2026-07-29/30). Neither code appears in ngadmin, which only ever spoke to the
# older v1 firmware. This firmware wants the v2 salted challenge-response
# (AUTH_V2_ENCPASS 0x0014 answers 0x10; AUTH_V2_SALT 0x0017 is readable and
# rotates on every read; AUTH_V2_PASSWORD 0x001A is write-only) -- an algorithm
# this library now IMPLEMENTS and has verified end to end against that hardware
# (see ``auth.auth_v2_password``), so neither code means "scheme unsupported":
#
#   13 -- the write's authentication was refused. Two causes share the code and
#         the error-attr field tells them apart: attr 0x000A (ATTR_PASSWORD)
#         means a v1-XOR/plaintext PASSWORD TLV was offered to a v2-only
#         firmware; any other attr means the v2 token itself was wrong (wrong
#         admin password, or a token folded against a stale salt).
#   14 -- write LOCKOUT after repeated rapid failures. The switch then stops
#         answering WRITE_REQUESTs at all for a cooldown; READs keep working.
ERROR_AUTH_REJECTED = 13
ERROR_LOCKED = 14

ERROR_NAMES = {
    ERROR_NONE: "none",
    ERROR_READONLY: "attribute not readable",
    ERROR_WRITEONLY: "attribute not writable",
    ERROR_INVALID_VALUE: "invalid value",
    ERROR_DENIED: "denied",
    ERROR_AUTH_REJECTED: "write authentication rejected",
    ERROR_LOCKED: "write locked out after repeated auth failures",
}


[docs] class Op(IntEnum): """NSDP operation codes (header byte 1). READ_REQUEST/RESPONSE are used for discovery and property queries. WRITE_REQUEST/RESPONSE are used to modify switch configuration (requires authentication via Tag.PASSWORD or Tag.AUTH_V2_PASSWORD). """ READ_REQUEST = 0x01 READ_RESPONSE = 0x02 WRITE_REQUEST = 0x03 WRITE_RESPONSE = 0x04
[docs] class Tag(IntEnum): """NSDP TLV tag identifiers. Each tag represents a switch property. Tags are 16-bit unsigned integers encoded big-endian in the packet. See ``gdoc2netcfg/docs/nsdp-protocol.md`` (TLV Tag Registry) for byte-level encoding details of each tag's value field. """ # Packet markers START_OF_MARK = 0x0000 END_OF_MARK = 0xFFFF # Device identity MODEL = 0x0001 HOSTNAME = 0x0003 MAC = 0x0004 LOCATION = 0x0005 IP_ADDRESS = 0x0006 NETMASK = 0x0007 GATEWAY = 0x0008 DHCP_MODE = 0x000B FIRMWARE_VER_1 = 0x000D FIRMWARE_VER_2 = 0x000E PORT_COUNT = 0x6000 SERIAL_NUMBER = 0x7800 # Authentication PASSWORD = 0x000A # Encryption-type probe (ngadmin's ATTR_ENCPASS): a switch answers this # 4-byte value to advertise which write-auth scheme it wants. Value 1 = # legacy v1 XOR (Tag.PASSWORD); value 0x10 = v2 salted challenge-response # (AUTH_V2_SALT / AUTH_V2_PASSWORD). Observed 0x00000010 on a GS110EMX # (fw 1.0.2.8). AUTH_V2_ENCPASS = 0x0014 AUTH_V2_SALT = 0x0017 AUTH_V2_PASSWORD = 0x001A # Port information PORT_STATUS = 0x0C00 PORT_STATISTICS = 0x1000 # Per-port operator description ("Port Description" in the web UI). # LIVE-MEASURED on all three real GS110EMX units (10.1.5.25/.26/.27, fw # 1.0.2.8, 2026-07-30): one TLV per port, byte 0 = port number, the rest = # the description string (absent = a 1-byte TLV). Cross-checked byte-for- # byte against each switch's own /iss/specific/port_settings.html "Port # Description" column across all 30 ports -- e.g. 10.1.5.26 port 2 answers # ``02 7270692d7364722d6b72616b656e`` for the page's "rpi-sdr-kraken". # Absent from ngadmin/ProSafeLinux (both predate this firmware family). PORT_NAME = 0xB000 # VLAN VLAN_ENGINE = 0x2000 VLAN_PORT_CONF = 0x2400 # port-based (non-802.1Q) VLAN membership VLAN_MEMBERS = 0x2800 # VLAN destroy (write-only action carrying the 2-byte VLAN id). GROUNDED in # ngadmin (herveboisse/ngadmin) ``lib/src/vlan.c::ngadmin_VLANDestroy``: # pushBackList(attr, newShortAttr(ATTR_VLAN_DESTROY, vlan)); # return writeRequest(nga, attr); # with ``#define ATTR_VLAN_DESTROY 0x2C00`` in raw/include/nsdp/protocol.h. # A READ of 0x2C00 on a real GS110EMX (10.1.5.25, fw 1.0.2.8) answers # error=3 "attribute not readable" -- exactly what every other write-only # action tag answers there (REBOOT 0x0013, FACTORY_RESET 0x0400), so that # read error is consistent with the tag existing, not with it being absent. VLAN_DESTROY = 0x2C00 MAX_VLAN = 0x6400 # max simultaneous VLANs; 0x40 (=64) measured on GS110EMX PORT_PVID = 0x3000 # QoS QOS_ENGINE = 0x3400 PORT_QOS_PRIORITY = 0x3800 # Traffic control INGRESS_RATE_LIMIT = 0x4C00 EGRESS_RATE_LIMIT = 0x5000 BROADCAST_FILTERING = 0x5400 BROADCAST_BANDWIDTH = 0x5800 PORT_MIRRORING = 0x5C00 # IGMP IGMP_SNOOPING = 0x6800 BLOCK_UNKNOWN_MULTICAST = 0x6C00 IGMPV3_HEADER_VALIDATION = 0x7000 IGMP_STATIC_ROUTER_PORTS = 0x8000 # Other LOOP_DETECTION = 0x9000 ACTIVE_FIRMWARE = 0x000C # Actions (write-only) REBOOT = 0x0013 FACTORY_RESET = 0x0400
[docs] @dataclass(frozen=True) class TLVEntry: """One NSDP TLV: a 2-byte tag, 2-byte length, then that many value bytes.""" tag: Tag | int value: bytes = b""
[docs] def encode(self) -> bytes: return struct.pack(">HH", int(self.tag), len(self.value)) + self.value
[docs] @classmethod def decode(cls, data: bytes) -> tuple[TLVEntry, int]: if len(data) < 4: raise ValueError("NSDP TLV shorter than its 4-byte header") tag_raw, length = struct.unpack_from(">HH", data, 0) if len(data) < 4 + length: raise ValueError( f"NSDP TLV declares {length} value bytes but only " f"{len(data) - 4} are present" ) value = data[4 : 4 + length] tag: Tag | int try: tag = Tag(tag_raw) except ValueError: tag = tag_raw # unknown/uncatalogued tag: keep the raw int return cls(tag=tag, value=value), 4 + length
[docs] @dataclass class NSDPPacket: """A full NSDP datagram: a fixed header plus a list of TLVs.""" op: Op client_mac: bytes server_mac: bytes = b"\x00" * 6 sequence: int = 0 result: int = 0 tlvs: list[TLVEntry] = field(default_factory=list) # The TLV tag the switch blamed for ``result`` (header bytes 4-5); 0 when # there is no error. Requests always send 0. ``result`` itself conflates the # error code with a trailing unk1 byte (always 0), so the code alone is # ``result >> 8`` -- see ``error_code`` below and ``client.check_result``. error_attr: int = 0 @property def error_code(self) -> int: """The switch's error code alone (header byte 2). ``result`` is the 16-bit field bytes 2-3; only its high byte is the error code (byte 3 is always 0 on every real reply captured so far), so ``result == 0x0300`` means error code 3. """ return (self.result >> 8) & 0xFF
[docs] def add_tlv(self, tag: Tag | int, value: bytes = b"") -> None: self.tlvs.append(TLVEntry(tag=tag, value=value))
[docs] def encode(self) -> bytes: header = struct.pack( HEADER_FORMAT, 0x01, int(self.op), self.result, self.error_attr, b"\x00" * 2, self.client_mac, self.server_mac, self.sequence, NSDP_SIGNATURE, b"\x00" * 4, ) body = b"".join(t.encode() for t in self.tlvs) return header + body + END_MARKER
[docs] @classmethod def decode(cls, data: bytes) -> NSDPPacket: if len(data) < HEADER_SIZE: raise ValueError(f"NSDP packet shorter than {HEADER_SIZE}-byte header") ( _version, op_raw, result, error_attr, _reserved1, client_mac, server_mac, sequence, signature, _reserved3, ) = struct.unpack(HEADER_FORMAT, data[:HEADER_SIZE]) if signature != NSDP_SIGNATURE: raise ValueError(f"bad NSDP signature {signature!r}") tlvs: list[TLVEntry] = [] offset = HEADER_SIZE while offset + 4 <= len(data): entry, consumed = TLVEntry.decode(data[offset:]) if entry.tag == Tag.END_OF_MARK: break tlvs.append(entry) offset += consumed return cls( op=Op(op_raw), client_mac=client_mac, server_mac=server_mac, sequence=sequence, result=result, error_attr=error_attr, tlvs=tlvs, )