python-netgear-switch-library 0.0.post154__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- netgear_switch/__init__.py +132 -0
- netgear_switch/_dispatch.py +178 -0
- netgear_switch/_version.py +24 -0
- netgear_switch/aio_api.py +529 -0
- netgear_switch/cli/__init__.py +1 -0
- netgear_switch/cli/capture.py +131 -0
- netgear_switch/cli/context.py +39 -0
- netgear_switch/cli/format.py +201 -0
- netgear_switch/cli/main.py +484 -0
- netgear_switch/cli/resolve.py +108 -0
- netgear_switch/cli/safety.py +71 -0
- netgear_switch/config.py +184 -0
- netgear_switch/errors.py +52 -0
- netgear_switch/http_read.py +174 -0
- netgear_switch/http_write.py +420 -0
- netgear_switch/models.py +156 -0
- netgear_switch/nsdp_read.py +221 -0
- netgear_switch/nsdp_write.py +315 -0
- netgear_switch/protocols/__init__.py +1 -0
- netgear_switch/protocols/http/__init__.py +1 -0
- netgear_switch/protocols/http/crypt.py +29 -0
- netgear_switch/protocols/http/endpoints.py +165 -0
- netgear_switch/protocols/http/forms.py +77 -0
- netgear_switch/protocols/http/parse.py +238 -0
- netgear_switch/protocols/http/session.py +29 -0
- netgear_switch/protocols/nsdp/__init__.py +7 -0
- netgear_switch/protocols/nsdp/auth.py +33 -0
- netgear_switch/protocols/nsdp/client.py +67 -0
- netgear_switch/protocols/nsdp/parsers.py +209 -0
- netgear_switch/protocols/nsdp/protocol.py +201 -0
- netgear_switch/protocols/nsdp/types.py +137 -0
- netgear_switch/protocols/nsdp/write.py +98 -0
- netgear_switch/protocols/snmp/__init__.py +1 -0
- netgear_switch/protocols/snmp/client.py +88 -0
- netgear_switch/protocols/snmp/oids.py +125 -0
- netgear_switch/protocols/snmp/parse.py +777 -0
- netgear_switch/protocols/snmp/write.py +112 -0
- netgear_switch/py.typed +0 -0
- netgear_switch/registry.py +227 -0
- netgear_switch/snmp_read.py +226 -0
- netgear_switch/snmp_write.py +625 -0
- netgear_switch/sync_api.py +557 -0
- netgear_switch/transport/__init__.py +1 -0
- netgear_switch/transport/aio/__init__.py +1 -0
- netgear_switch/transport/aio/nsdp_udp.py +152 -0
- netgear_switch/transport/aio/snmp_pysnmp.py +247 -0
- netgear_switch/transport/http/__init__.py +1 -0
- netgear_switch/transport/http/client.py +217 -0
- netgear_switch/transport/sync/__init__.py +1 -0
- netgear_switch/transport/sync/nsdp_udp.py +109 -0
- netgear_switch/transport/sync/snmp_netsnmp_cli.py +257 -0
- netgear_switch/virtual/__init__.py +8 -0
- netgear_switch/virtual/faces/__init__.py +2 -0
- netgear_switch/virtual/faces/http.py +164 -0
- netgear_switch/virtual/faces/mibview.py +92 -0
- netgear_switch/virtual/faces/nsdp.py +124 -0
- netgear_switch/virtual/faces/snmp.py +412 -0
- netgear_switch/virtual/seed.py +220 -0
- netgear_switch/virtual/server.py +106 -0
- netgear_switch/virtual/state.py +615 -0
- netgear_switch/virtual/web.py +210 -0
- python_netgear_switch_library-0.0.post154.dist-info/METADATA +85 -0
- python_netgear_switch_library-0.0.post154.dist-info/RECORD +66 -0
- python_netgear_switch_library-0.0.post154.dist-info/WHEEL +4 -0
- python_netgear_switch_library-0.0.post154.dist-info/entry_points.txt +2 -0
- python_netgear_switch_library-0.0.post154.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Resolve the target ``SyncSwitch`` from CLI args (inventory or host+model).
|
|
2
|
+
|
|
3
|
+
Credential precedence (design spec Sec5.1): CLI flag -> environment variable ->
|
|
4
|
+
config value -> interactive prompt.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from netgear_switch.config import load_inventory
|
|
13
|
+
from netgear_switch.errors import ConfigError
|
|
14
|
+
from netgear_switch.registry import get_model
|
|
15
|
+
from netgear_switch.sync_api import SyncSwitch
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
import argparse
|
|
19
|
+
from collections.abc import Callable, Mapping
|
|
20
|
+
|
|
21
|
+
from netgear_switch.config import SwitchConfig
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _read_community(
|
|
25
|
+
args: argparse.Namespace,
|
|
26
|
+
env: Mapping[str, str],
|
|
27
|
+
config_value: str | None,
|
|
28
|
+
prompt: Callable[[str], str] | None,
|
|
29
|
+
) -> str | None:
|
|
30
|
+
if args.community:
|
|
31
|
+
return str(args.community)
|
|
32
|
+
if env.get("NGSW_COMMUNITY"):
|
|
33
|
+
return env["NGSW_COMMUNITY"]
|
|
34
|
+
if config_value:
|
|
35
|
+
return config_value
|
|
36
|
+
if prompt is not None:
|
|
37
|
+
typed = prompt("SNMP read community: ")
|
|
38
|
+
# A bare Enter at the prompt must NOT become a literal empty-string
|
|
39
|
+
# SNMP community; treat it as unresolved so the library's existing
|
|
40
|
+
# lazy CredentialError fires at SNMP-build time instead. (CLI/env/
|
|
41
|
+
# config tiers are out of scope here -- separate hardening later.)
|
|
42
|
+
return typed if typed.strip() else None
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _write_community_override(
|
|
47
|
+
args: argparse.Namespace, env: Mapping[str, str]
|
|
48
|
+
) -> str | None:
|
|
49
|
+
if args.write_community:
|
|
50
|
+
return str(args.write_community)
|
|
51
|
+
return env.get("NGSW_WRITE_COMMUNITY")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _from_inventory(
|
|
55
|
+
args: argparse.Namespace,
|
|
56
|
+
env: Mapping[str, str],
|
|
57
|
+
prompt: Callable[[str], str] | None,
|
|
58
|
+
) -> SyncSwitch:
|
|
59
|
+
if not args.config:
|
|
60
|
+
raise ConfigError("--switch requires --config <inventory.toml>")
|
|
61
|
+
inventory = load_inventory(args.config, env=env)
|
|
62
|
+
try:
|
|
63
|
+
cfg: SwitchConfig = inventory[args.switch]
|
|
64
|
+
except KeyError:
|
|
65
|
+
raise ConfigError(
|
|
66
|
+
f"switch {args.switch!r} not found in {args.config}"
|
|
67
|
+
) from None
|
|
68
|
+
community = _read_community(args, env, cfg.snmp_community, prompt)
|
|
69
|
+
write_override = _write_community_override(args, env)
|
|
70
|
+
return SyncSwitch(
|
|
71
|
+
cfg.model,
|
|
72
|
+
cfg.host,
|
|
73
|
+
snmp_community=community,
|
|
74
|
+
snmp_write_community=write_override,
|
|
75
|
+
snmp_write_community_resolver=lambda: cfg.snmp_write_community(env=env),
|
|
76
|
+
protected_ports=cfg.protected_ports,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def resolve_switch(
|
|
81
|
+
args: argparse.Namespace,
|
|
82
|
+
*,
|
|
83
|
+
env: Mapping[str, str] | None = None,
|
|
84
|
+
prompt: Callable[[str], str] | None = None,
|
|
85
|
+
) -> SyncSwitch:
|
|
86
|
+
"""Build a ``SyncSwitch`` from ``--config``/``--switch``/``--host``/``--model``.
|
|
87
|
+
|
|
88
|
+
Resolution: an inventory lookup (``--switch``, requires ``--config``) wins
|
|
89
|
+
when given; otherwise ``--host``/``--model`` build a switch directly.
|
|
90
|
+
Credential precedence for the SNMP read community is CLI flag ->
|
|
91
|
+
``NGSW_COMMUNITY`` env var -> inventory config value -> ``prompt`` (if
|
|
92
|
+
supplied). The write community only ever comes from a CLI flag or
|
|
93
|
+
``NGSW_WRITE_COMMUNITY``/inventory spec, resolved lazily by ``SyncSwitch``.
|
|
94
|
+
"""
|
|
95
|
+
env = os.environ if env is None else env
|
|
96
|
+
if args.switch:
|
|
97
|
+
return _from_inventory(args, env, prompt)
|
|
98
|
+
if args.host and args.model:
|
|
99
|
+
community = _read_community(args, env, None, prompt)
|
|
100
|
+
return SyncSwitch(
|
|
101
|
+
get_model(args.model),
|
|
102
|
+
args.host,
|
|
103
|
+
snmp_community=community,
|
|
104
|
+
snmp_write_community=_write_community_override(args, env),
|
|
105
|
+
)
|
|
106
|
+
raise ConfigError(
|
|
107
|
+
"specify --switch <name> (with --config) or both --host and --model"
|
|
108
|
+
)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Write-safety gates for disruptive ngsw commands (design spec §6)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
from .context import EXIT_ERROR, EXIT_OK
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
import argparse
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
|
|
12
|
+
from .context import CliContext
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def add_write_args(parser: argparse.ArgumentParser) -> None:
|
|
16
|
+
"""Attach the shared --dry-run / --yes / --force gates to a subparser."""
|
|
17
|
+
parser.add_argument(
|
|
18
|
+
"--dry-run",
|
|
19
|
+
action="store_true",
|
|
20
|
+
help="print the operation that would be sent, then send nothing",
|
|
21
|
+
)
|
|
22
|
+
parser.add_argument(
|
|
23
|
+
"-y", "--yes", action="store_true", help="skip the confirmation prompt"
|
|
24
|
+
)
|
|
25
|
+
parser.add_argument(
|
|
26
|
+
"--force",
|
|
27
|
+
action="store_true",
|
|
28
|
+
help="override protected_ports and other force-gates",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def confirm(prompt: str, *, assume_yes: bool, ctx: CliContext) -> bool:
|
|
33
|
+
"""Ask for confirmation on stderr; read one line from ctx.inp."""
|
|
34
|
+
if assume_yes:
|
|
35
|
+
return True
|
|
36
|
+
print(f"{prompt} [y/N]: ", end="", file=ctx.err)
|
|
37
|
+
ctx.err.flush()
|
|
38
|
+
reply = ctx.inp.readline().strip().lower()
|
|
39
|
+
return reply in {"y", "yes"}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def do_write(
|
|
43
|
+
ctx: CliContext,
|
|
44
|
+
*,
|
|
45
|
+
dry_run: bool,
|
|
46
|
+
assume_yes: bool,
|
|
47
|
+
host: str,
|
|
48
|
+
description: str,
|
|
49
|
+
action: Callable[[], None],
|
|
50
|
+
warning: str | None = None,
|
|
51
|
+
) -> int:
|
|
52
|
+
"""The single disruptive-write gate: dry-run -> confirm -> execute -> report.
|
|
53
|
+
|
|
54
|
+
``action`` is the verify-after-write facade call; any NetgearSwitchError it
|
|
55
|
+
raises propagates to main() for clean reporting. The CLI describes the
|
|
56
|
+
operation at facade granularity (method + args + host) rather than
|
|
57
|
+
re-encoding the SNMP SET / NSDP packet / HTTP form, so no library logic is
|
|
58
|
+
duplicated here.
|
|
59
|
+
"""
|
|
60
|
+
if dry_run:
|
|
61
|
+
print(f"DRY-RUN: would {description} on {host} (nothing sent)", file=ctx.out)
|
|
62
|
+
return EXIT_OK
|
|
63
|
+
prompt = f"About to {description} on {host}."
|
|
64
|
+
if warning:
|
|
65
|
+
prompt = f"{warning}\n{prompt}"
|
|
66
|
+
if not confirm(prompt, assume_yes=assume_yes, ctx=ctx):
|
|
67
|
+
print("aborted: no changes made", file=ctx.err)
|
|
68
|
+
return EXIT_ERROR
|
|
69
|
+
action()
|
|
70
|
+
print(f"ok: {description}", file=ctx.out)
|
|
71
|
+
return EXIT_OK
|
netgear_switch/config.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""TOML inventory loading and credential resolution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shlex
|
|
7
|
+
import subprocess
|
|
8
|
+
import tomllib
|
|
9
|
+
from collections.abc import Callable, Mapping
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from .errors import ConfigError, CredentialError
|
|
14
|
+
from .registry import SwitchModel, get_model
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from typing import TypeGuard
|
|
18
|
+
|
|
19
|
+
Runner = Callable[..., subprocess.CompletedProcess[str]]
|
|
20
|
+
|
|
21
|
+
_SECRET_COMMAND_TIMEOUT = 10
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def resolve_secret(
|
|
25
|
+
spec: str | None,
|
|
26
|
+
*,
|
|
27
|
+
env: Mapping[str, str],
|
|
28
|
+
runner: Runner = subprocess.run,
|
|
29
|
+
) -> str | None:
|
|
30
|
+
"""Resolve one secret spec to its value (or None)."""
|
|
31
|
+
if spec is None:
|
|
32
|
+
return None
|
|
33
|
+
if spec.startswith("${") and spec.endswith("}"):
|
|
34
|
+
name = spec[2:-1]
|
|
35
|
+
try:
|
|
36
|
+
return env[name]
|
|
37
|
+
except KeyError:
|
|
38
|
+
raise CredentialError(f"environment variable {name!r} is not set") from None
|
|
39
|
+
if spec.startswith("!"):
|
|
40
|
+
args = shlex.split(spec[1:])
|
|
41
|
+
if not args:
|
|
42
|
+
raise CredentialError("empty command in secret spec")
|
|
43
|
+
try:
|
|
44
|
+
result = runner(
|
|
45
|
+
args, capture_output=True, text=True, timeout=_SECRET_COMMAND_TIMEOUT
|
|
46
|
+
)
|
|
47
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
48
|
+
raise CredentialError(
|
|
49
|
+
f"secret command {args!r} could not be run: {exc}"
|
|
50
|
+
) from exc
|
|
51
|
+
if result.returncode != 0:
|
|
52
|
+
raise CredentialError(
|
|
53
|
+
f"secret command {args!r} failed "
|
|
54
|
+
f"(exit {result.returncode}): {result.stderr.strip()}"
|
|
55
|
+
)
|
|
56
|
+
return result.stdout.strip()
|
|
57
|
+
return spec
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _is_literal(spec: str | None) -> TypeGuard[str]:
|
|
61
|
+
if spec is None:
|
|
62
|
+
return False
|
|
63
|
+
return not (spec.startswith("${") and spec.endswith("}")) and not spec.startswith(
|
|
64
|
+
"!"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def ensure_secure_file(path: os.PathLike[str] | str) -> None:
|
|
69
|
+
"""Raise if the file is readable/writable by group or other."""
|
|
70
|
+
mode = os.stat(path).st_mode
|
|
71
|
+
if mode & 0o077:
|
|
72
|
+
raise ConfigError(
|
|
73
|
+
f"{os.fspath(path)} has insecure permissions {oct(mode & 0o777)}; "
|
|
74
|
+
"chmod 600 it (contains a literal secret)"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class SwitchConfig:
|
|
80
|
+
name: str
|
|
81
|
+
model: SwitchModel
|
|
82
|
+
host: str
|
|
83
|
+
snmp_community: str | None
|
|
84
|
+
snmp_write_community_spec: str | None
|
|
85
|
+
http_password_spec: str | None
|
|
86
|
+
nsdp_interface: str | None
|
|
87
|
+
protected_ports: frozenset[int]
|
|
88
|
+
|
|
89
|
+
def snmp_write_community(
|
|
90
|
+
self, *, env: Mapping[str, str], runner: Runner = subprocess.run
|
|
91
|
+
) -> str | None:
|
|
92
|
+
return resolve_secret(self.snmp_write_community_spec, env=env, runner=runner)
|
|
93
|
+
|
|
94
|
+
def http_password(
|
|
95
|
+
self, *, env: Mapping[str, str], runner: Runner = subprocess.run
|
|
96
|
+
) -> str | None:
|
|
97
|
+
return resolve_secret(self.http_password_spec, env=env, runner=runner)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _switch_from_table(
|
|
101
|
+
name: str, table: Mapping[str, object]
|
|
102
|
+
) -> tuple[SwitchConfig, list[str]]:
|
|
103
|
+
try:
|
|
104
|
+
model_key = table["model"]
|
|
105
|
+
host = table["host"]
|
|
106
|
+
except KeyError as exc:
|
|
107
|
+
raise ConfigError(
|
|
108
|
+
f"switch {name!r} is missing required key {exc.args[0]!r}"
|
|
109
|
+
) from None
|
|
110
|
+
if not isinstance(model_key, str) or not isinstance(host, str):
|
|
111
|
+
raise ConfigError(f"switch {name!r}: 'model' and 'host' must be strings")
|
|
112
|
+
|
|
113
|
+
snmp = table.get("snmp", {})
|
|
114
|
+
http = table.get("http", {})
|
|
115
|
+
nsdp = table.get("nsdp", {})
|
|
116
|
+
if (
|
|
117
|
+
not isinstance(snmp, Mapping)
|
|
118
|
+
or not isinstance(http, Mapping)
|
|
119
|
+
or not isinstance(nsdp, Mapping)
|
|
120
|
+
):
|
|
121
|
+
raise ConfigError(f"switch {name!r}: snmp/http/nsdp must be tables")
|
|
122
|
+
|
|
123
|
+
ports = table.get("protected_ports", [])
|
|
124
|
+
if not isinstance(ports, list) or not all(
|
|
125
|
+
isinstance(p, int) and not isinstance(p, bool) for p in ports
|
|
126
|
+
):
|
|
127
|
+
raise ConfigError(f"switch {name!r}: protected_ports must be a list of ints")
|
|
128
|
+
|
|
129
|
+
for label, value in (
|
|
130
|
+
("snmp.community", snmp.get("community")),
|
|
131
|
+
("snmp.write_community", snmp.get("write_community")),
|
|
132
|
+
("http.password", http.get("password")),
|
|
133
|
+
("nsdp.interface", nsdp.get("interface")),
|
|
134
|
+
):
|
|
135
|
+
if value is not None and not isinstance(value, str):
|
|
136
|
+
raise ConfigError(f"switch {name!r}: {label} must be a string")
|
|
137
|
+
|
|
138
|
+
secret_specs = [
|
|
139
|
+
snmp.get("write_community"),
|
|
140
|
+
http.get("password"),
|
|
141
|
+
]
|
|
142
|
+
literals = [s for s in secret_specs if _is_literal(s)]
|
|
143
|
+
|
|
144
|
+
cfg = SwitchConfig(
|
|
145
|
+
name=name,
|
|
146
|
+
model=get_model(model_key),
|
|
147
|
+
host=host,
|
|
148
|
+
snmp_community=snmp.get("community"),
|
|
149
|
+
snmp_write_community_spec=snmp.get("write_community"),
|
|
150
|
+
http_password_spec=http.get("password"),
|
|
151
|
+
nsdp_interface=nsdp.get("interface"),
|
|
152
|
+
protected_ports=frozenset(ports),
|
|
153
|
+
)
|
|
154
|
+
return cfg, literals
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def load_inventory(
|
|
158
|
+
path: os.PathLike[str] | str,
|
|
159
|
+
*,
|
|
160
|
+
env: Mapping[str, str] | None = None,
|
|
161
|
+
) -> dict[str, SwitchConfig]:
|
|
162
|
+
"""Load a TOML inventory into a {name: SwitchConfig} dict."""
|
|
163
|
+
if env is None:
|
|
164
|
+
env = os.environ
|
|
165
|
+
with open(path, "rb") as fh:
|
|
166
|
+
data = tomllib.load(fh)
|
|
167
|
+
|
|
168
|
+
switches = data.get("switches", {})
|
|
169
|
+
if not isinstance(switches, Mapping):
|
|
170
|
+
raise ConfigError("top-level [switches] must be a table")
|
|
171
|
+
|
|
172
|
+
result: dict[str, SwitchConfig] = {}
|
|
173
|
+
any_literal = False
|
|
174
|
+
for name, table in switches.items():
|
|
175
|
+
if not isinstance(table, Mapping):
|
|
176
|
+
raise ConfigError(f"[switches.{name}] must be a table")
|
|
177
|
+
cfg, literals = _switch_from_table(name, table)
|
|
178
|
+
if literals:
|
|
179
|
+
any_literal = True
|
|
180
|
+
result[name] = cfg
|
|
181
|
+
|
|
182
|
+
if any_literal:
|
|
183
|
+
ensure_secure_file(path)
|
|
184
|
+
return result
|
netgear_switch/errors.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Exception hierarchy for netgear_switch."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class NetgearSwitchError(Exception):
|
|
7
|
+
"""Base class for every error raised by this library."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ConfigError(NetgearSwitchError):
|
|
11
|
+
"""The inventory/config file is malformed or invalid."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CredentialError(NetgearSwitchError):
|
|
15
|
+
"""A required secret could not be resolved from any source."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class UnknownModelError(NetgearSwitchError):
|
|
19
|
+
"""A switch references a model key that is not in the registry."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class UnsupportedCapabilityError(NetgearSwitchError):
|
|
23
|
+
"""The requested operation is not available on this model/backend."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ProtectedPortError(NetgearSwitchError):
|
|
27
|
+
"""A disruptive write targeted a protected port without force=True."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class WriteVerificationError(NetgearSwitchError):
|
|
31
|
+
"""A write did not read back as expected.
|
|
32
|
+
|
|
33
|
+
Carries the observed state before and after the write attempt so callers
|
|
34
|
+
can report exactly what diverged.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, message: str, *, before: object, after: object) -> None:
|
|
38
|
+
super().__init__(message)
|
|
39
|
+
self.before = before
|
|
40
|
+
self.after = after
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class HttpError(NetgearSwitchError):
|
|
44
|
+
"""An HTTP web-UI transport operation failed (connect, HTTP status, page shape)."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class HttpAuthError(HttpError):
|
|
48
|
+
"""Web-UI login was rejected, or an authenticated session was lost."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class HttpUnexpectedPageError(HttpError):
|
|
52
|
+
"""A web-UI page or token could not be parsed into the expected shape."""
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Model-driven web-UI read operations over a sync or async ``HttpSession``.
|
|
2
|
+
|
|
3
|
+
Parallel to ``snmp_read.py``/``nsdp_read.py``. Construction is gated on
|
|
4
|
+
``HttpModelSpec.reads_verified``: a model whose web reads are still
|
|
5
|
+
UNVERIFIED-pending-capture (gs110emx Gambit, gsm7228ps cheetah/S3300) refuses
|
|
6
|
+
to construct rather than return fabricated data -- the facade never gets a
|
|
7
|
+
plausible-but-wrong result from an unverified scrape. Web-UI-impossible ops
|
|
8
|
+
(MAC/FDB, box sensors, LLDP, management-IP config) raise
|
|
9
|
+
``UnsupportedCapabilityError`` honestly instead of silently returning ``[]``.
|
|
10
|
+
|
|
11
|
+
All page-path selection and HTML-to-model conversion lives in the
|
|
12
|
+
module-level helpers below (pure, I/O-free); ``HttpReader``/``AsyncHttpReader``
|
|
13
|
+
differ only in whether ``session.get_page``/``post_form`` is awaited.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from typing import TYPE_CHECKING
|
|
18
|
+
|
|
19
|
+
from .errors import UnsupportedCapabilityError
|
|
20
|
+
from .models import VLANInfo, VlanMode
|
|
21
|
+
from .protocols.http import parse
|
|
22
|
+
from .protocols.http.endpoints import http_spec
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from .models import (
|
|
26
|
+
LLDPNeighbor,
|
|
27
|
+
MacEntry,
|
|
28
|
+
MgmtIpConfig,
|
|
29
|
+
PoEStatus,
|
|
30
|
+
PortStats,
|
|
31
|
+
PortStatus,
|
|
32
|
+
Sensor,
|
|
33
|
+
)
|
|
34
|
+
from .protocols.http.endpoints import HttpModelSpec
|
|
35
|
+
from .protocols.http.session import AsyncHttpSession, HttpSession
|
|
36
|
+
from .registry import SwitchModel
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _require_verified_reads(spec: HttpModelSpec) -> None:
|
|
40
|
+
if not spec.reads_verified:
|
|
41
|
+
raise UnsupportedCapabilityError(
|
|
42
|
+
f"model {spec.model_key!r} HTTP reads are UNVERIFIED-pending-capture"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _unsupported(model_key: str, op: str) -> UnsupportedCapabilityError:
|
|
47
|
+
return UnsupportedCapabilityError(
|
|
48
|
+
f"model {model_key!r} web UI does not expose {op}"
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _require_path(model_key: str, path: str | None, op: str) -> str:
|
|
53
|
+
"""Return ``path`` or raise honestly if this model's spec has none for ``op``."""
|
|
54
|
+
if path is None:
|
|
55
|
+
raise _unsupported(model_key, op)
|
|
56
|
+
return path
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _vlan_info(vid: int, membership_html: str, port_count: int) -> VLANInfo:
|
|
60
|
+
"""Pure conversion of one 8021qMembe.cgi response into a ``VLANInfo``."""
|
|
61
|
+
states = parse.parse_membership(membership_html, port_count)
|
|
62
|
+
tagged = frozenset(p for p, m in states.items() if m is VlanMode.TAGGED)
|
|
63
|
+
untagged = frozenset(p for p, m in states.items() if m is VlanMode.UNTAGGED)
|
|
64
|
+
return VLANInfo(
|
|
65
|
+
vlan_id=vid,
|
|
66
|
+
name=None,
|
|
67
|
+
member_ports=tagged | untagged,
|
|
68
|
+
tagged_ports=tagged,
|
|
69
|
+
untagged_ports=untagged,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class HttpReader:
|
|
74
|
+
"""Synchronous web-UI read facade over one switch."""
|
|
75
|
+
|
|
76
|
+
def __init__(self, session: HttpSession, model: SwitchModel) -> None:
|
|
77
|
+
self._spec = http_spec(model)
|
|
78
|
+
_require_verified_reads(self._spec)
|
|
79
|
+
self.session = session
|
|
80
|
+
self.model = model
|
|
81
|
+
|
|
82
|
+
def get_ports(self) -> list[PortStatus]:
|
|
83
|
+
path = _require_path(self.model.key, self._spec.dashboard_path, "port status")
|
|
84
|
+
return parse.parse_port_status(self.session.get_page(path))
|
|
85
|
+
|
|
86
|
+
def get_stats(self) -> list[PortStats]:
|
|
87
|
+
path = _require_path(self.model.key, self._spec.stats_path, "port statistics")
|
|
88
|
+
return parse.parse_port_stats(self.session.get_page(path))
|
|
89
|
+
|
|
90
|
+
def get_poe(self) -> list[PoEStatus]:
|
|
91
|
+
path = _require_path(self.model.key, self._spec.poe_status_path, "PoE status")
|
|
92
|
+
return parse.parse_poe_status(self.session.get_page(path))
|
|
93
|
+
|
|
94
|
+
def get_pvids(self) -> list[tuple[int, int]]:
|
|
95
|
+
path = _require_path(self.model.key, self._spec.pvid_path, "port PVIDs")
|
|
96
|
+
return parse.parse_pvids(self.session.get_page(path))
|
|
97
|
+
|
|
98
|
+
def get_vlans(self) -> list[VLANInfo]:
|
|
99
|
+
cfg_path = _require_path(
|
|
100
|
+
self.model.key, self._spec.vlan_config_path, "VLAN configuration"
|
|
101
|
+
)
|
|
102
|
+
member_path = _require_path(
|
|
103
|
+
self.model.key, self._spec.vlan_membership_path, "VLAN membership"
|
|
104
|
+
)
|
|
105
|
+
cfg = self.session.get_page(cfg_path)
|
|
106
|
+
result: list[VLANInfo] = []
|
|
107
|
+
for vid in parse.parse_vlan_ids(cfg):
|
|
108
|
+
html = self.session.post_form(member_path, {"VLAN_ID": str(vid)})
|
|
109
|
+
result.append(_vlan_info(vid, html, self.model.port_count))
|
|
110
|
+
return result
|
|
111
|
+
|
|
112
|
+
def get_macs(self) -> list[MacEntry]:
|
|
113
|
+
raise _unsupported(self.model.key, "a MAC/FDB table")
|
|
114
|
+
|
|
115
|
+
def get_lldp(self) -> list[LLDPNeighbor]:
|
|
116
|
+
raise _unsupported(self.model.key, "LLDP neighbours")
|
|
117
|
+
|
|
118
|
+
def get_sensors(self) -> list[Sensor]:
|
|
119
|
+
raise _unsupported(self.model.key, "box sensors")
|
|
120
|
+
|
|
121
|
+
def get_mgmt_ip(self) -> MgmtIpConfig:
|
|
122
|
+
raise _unsupported(self.model.key, "management-IP config")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class AsyncHttpReader:
|
|
126
|
+
"""Asynchronous web-UI read facade (mirror of ``HttpReader``)."""
|
|
127
|
+
|
|
128
|
+
def __init__(self, session: AsyncHttpSession, model: SwitchModel) -> None:
|
|
129
|
+
self._spec = http_spec(model)
|
|
130
|
+
_require_verified_reads(self._spec)
|
|
131
|
+
self.session = session
|
|
132
|
+
self.model = model
|
|
133
|
+
|
|
134
|
+
async def get_ports(self) -> list[PortStatus]:
|
|
135
|
+
path = _require_path(self.model.key, self._spec.dashboard_path, "port status")
|
|
136
|
+
return parse.parse_port_status(await self.session.get_page(path))
|
|
137
|
+
|
|
138
|
+
async def get_stats(self) -> list[PortStats]:
|
|
139
|
+
path = _require_path(self.model.key, self._spec.stats_path, "port statistics")
|
|
140
|
+
return parse.parse_port_stats(await self.session.get_page(path))
|
|
141
|
+
|
|
142
|
+
async def get_poe(self) -> list[PoEStatus]:
|
|
143
|
+
path = _require_path(self.model.key, self._spec.poe_status_path, "PoE status")
|
|
144
|
+
return parse.parse_poe_status(await self.session.get_page(path))
|
|
145
|
+
|
|
146
|
+
async def get_pvids(self) -> list[tuple[int, int]]:
|
|
147
|
+
path = _require_path(self.model.key, self._spec.pvid_path, "port PVIDs")
|
|
148
|
+
return parse.parse_pvids(await self.session.get_page(path))
|
|
149
|
+
|
|
150
|
+
async def get_vlans(self) -> list[VLANInfo]:
|
|
151
|
+
cfg_path = _require_path(
|
|
152
|
+
self.model.key, self._spec.vlan_config_path, "VLAN configuration"
|
|
153
|
+
)
|
|
154
|
+
member_path = _require_path(
|
|
155
|
+
self.model.key, self._spec.vlan_membership_path, "VLAN membership"
|
|
156
|
+
)
|
|
157
|
+
cfg = await self.session.get_page(cfg_path)
|
|
158
|
+
result: list[VLANInfo] = []
|
|
159
|
+
for vid in parse.parse_vlan_ids(cfg):
|
|
160
|
+
html = await self.session.post_form(member_path, {"VLAN_ID": str(vid)})
|
|
161
|
+
result.append(_vlan_info(vid, html, self.model.port_count))
|
|
162
|
+
return result
|
|
163
|
+
|
|
164
|
+
async def get_macs(self) -> list[MacEntry]:
|
|
165
|
+
raise _unsupported(self.model.key, "a MAC/FDB table")
|
|
166
|
+
|
|
167
|
+
async def get_lldp(self) -> list[LLDPNeighbor]:
|
|
168
|
+
raise _unsupported(self.model.key, "LLDP neighbours")
|
|
169
|
+
|
|
170
|
+
async def get_sensors(self) -> list[Sensor]:
|
|
171
|
+
raise _unsupported(self.model.key, "box sensors")
|
|
172
|
+
|
|
173
|
+
async def get_mgmt_ip(self) -> MgmtIpConfig:
|
|
174
|
+
raise _unsupported(self.model.key, "management-IP config")
|