isemass 0.1.0__tar.gz
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.
- isemass-0.1.0/PKG-INFO +53 -0
- isemass-0.1.0/README.md +40 -0
- isemass-0.1.0/pyproject.toml +38 -0
- isemass-0.1.0/src/isemass/__init__.py +4 -0
- isemass-0.1.0/src/isemass/cli.py +231 -0
- isemass-0.1.0/src/isemass/coa.py +225 -0
- isemass-0.1.0/src/isemass/config.py +81 -0
- isemass-0.1.0/src/isemass/console.py +6 -0
- isemass-0.1.0/src/isemass/defaults.py +16 -0
- isemass-0.1.0/src/isemass/templates/__init__.py +2 -0
- isemass-0.1.0/src/isemass/templates/settings.toml +25 -0
isemass-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: isemass
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CLI framework for mass Cisco ISE related operations.
|
|
5
|
+
License-Expression: Apache-2.0
|
|
6
|
+
Requires-Dist: click>=8.1
|
|
7
|
+
Requires-Dist: platformdirs>=4.2
|
|
8
|
+
Requires-Dist: requests>=2.32
|
|
9
|
+
Requires-Dist: rich>=13.7
|
|
10
|
+
Requires-Dist: urllib3>=2.0
|
|
11
|
+
Requires-Python: >=3.12
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# isemass
|
|
15
|
+
|
|
16
|
+
`isemass` is a Python CLI for mass operations related to Cisco ISE.
|
|
17
|
+
|
|
18
|
+
The `coa` command performs Cisco ISE API Change-of-Authorization requests from MAC addresses
|
|
19
|
+
found in an input text file. The `swauth` command is still a placeholder for future switch SSH
|
|
20
|
+
reauthentication work.
|
|
21
|
+
|
|
22
|
+
## Development
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
uv sync
|
|
26
|
+
uv run isemass --help
|
|
27
|
+
uv run pytest
|
|
28
|
+
uv run ruff check .
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Commands
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
isemass init
|
|
35
|
+
isemass coa --input-file macs.txt --host ise-mnt.example.com --node ise-psn01
|
|
36
|
+
isemass coa --input-file macs.txt --host ise-mnt.example.com --node ise-psn01 --yes
|
|
37
|
+
isemass swauth
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`isemass init` creates `settings.toml` in the operating system's standard user config directory
|
|
41
|
+
for the `isemass` app.
|
|
42
|
+
|
|
43
|
+
`isemass coa` accepts colon, dash, and Cisco dotted MAC formats, normalizes them to uppercase
|
|
44
|
+
colon format, removes duplicates, prompts for the API password, previews the target MAC list, and
|
|
45
|
+
asks for confirmation before sending requests. Use `--insecure` or `insecure = true` only when you
|
|
46
|
+
need to skip HTTPS certificate validation.
|
|
47
|
+
|
|
48
|
+
## Input Config
|
|
49
|
+
|
|
50
|
+
Priority of configuration input is this order:
|
|
51
|
+
1. CLI arguments
|
|
52
|
+
2. settings.toml values
|
|
53
|
+
3. back-end defaults (in defaults.py)
|
isemass-0.1.0/README.md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# isemass
|
|
2
|
+
|
|
3
|
+
`isemass` is a Python CLI for mass operations related to Cisco ISE.
|
|
4
|
+
|
|
5
|
+
The `coa` command performs Cisco ISE API Change-of-Authorization requests from MAC addresses
|
|
6
|
+
found in an input text file. The `swauth` command is still a placeholder for future switch SSH
|
|
7
|
+
reauthentication work.
|
|
8
|
+
|
|
9
|
+
## Development
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
uv sync
|
|
13
|
+
uv run isemass --help
|
|
14
|
+
uv run pytest
|
|
15
|
+
uv run ruff check .
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Commands
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
isemass init
|
|
22
|
+
isemass coa --input-file macs.txt --host ise-mnt.example.com --node ise-psn01
|
|
23
|
+
isemass coa --input-file macs.txt --host ise-mnt.example.com --node ise-psn01 --yes
|
|
24
|
+
isemass swauth
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
`isemass init` creates `settings.toml` in the operating system's standard user config directory
|
|
28
|
+
for the `isemass` app.
|
|
29
|
+
|
|
30
|
+
`isemass coa` accepts colon, dash, and Cisco dotted MAC formats, normalizes them to uppercase
|
|
31
|
+
colon format, removes duplicates, prompts for the API password, previews the target MAC list, and
|
|
32
|
+
asks for confirmation before sending requests. Use `--insecure` or `insecure = true` only when you
|
|
33
|
+
need to skip HTTPS certificate validation.
|
|
34
|
+
|
|
35
|
+
## Input Config
|
|
36
|
+
|
|
37
|
+
Priority of configuration input is this order:
|
|
38
|
+
1. CLI arguments
|
|
39
|
+
2. settings.toml values
|
|
40
|
+
3. back-end defaults (in defaults.py)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["uv_build>=0.11.16,<0.12"]
|
|
3
|
+
build-backend = "uv_build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "isemass"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "CLI framework for mass Cisco ISE related operations."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
license = "Apache-2.0"
|
|
12
|
+
dependencies = [
|
|
13
|
+
"click>=8.1",
|
|
14
|
+
"platformdirs>=4.2",
|
|
15
|
+
"requests>=2.32",
|
|
16
|
+
"rich>=13.7",
|
|
17
|
+
"urllib3>=2.0",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.scripts]
|
|
21
|
+
isemass = "isemass.cli:cli"
|
|
22
|
+
|
|
23
|
+
[dependency-groups]
|
|
24
|
+
dev = [
|
|
25
|
+
"pytest>=8.0",
|
|
26
|
+
"ruff>=0.8",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[tool.hatch.build.targets.wheel]
|
|
30
|
+
packages = ["src/isemass"]
|
|
31
|
+
|
|
32
|
+
[tool.pytest.ini_options]
|
|
33
|
+
addopts = "-q"
|
|
34
|
+
pythonpath = ["src"]
|
|
35
|
+
|
|
36
|
+
[tool.ruff]
|
|
37
|
+
line-length = 100
|
|
38
|
+
target-version = "py312"
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Click command line interface for isemass."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
|
|
11
|
+
from isemass import __version__
|
|
12
|
+
from isemass import coa as coa_ops
|
|
13
|
+
from isemass.config import SettingsError, get_settings_path, load_settings, write_default_settings
|
|
14
|
+
from isemass.console import console
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _load_settings_for_cli() -> dict[str, dict[str, Any]]:
|
|
18
|
+
try:
|
|
19
|
+
return load_settings()
|
|
20
|
+
except SettingsError as exc:
|
|
21
|
+
raise click.ClickException(str(exc)) from exc
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _section(settings: dict[str, dict[str, Any]], name: str) -> dict[str, Any]:
|
|
25
|
+
values = settings.get(name, {})
|
|
26
|
+
if not isinstance(values, dict):
|
|
27
|
+
raise click.ClickException(f"Invalid settings: [{name}] must be a table.")
|
|
28
|
+
return values
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _resolve_option(cli_value: Any, config_value: Any) -> Any:
|
|
32
|
+
return cli_value if cli_value is not None else config_value
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _missing_required_options(options: dict[str, Any]) -> list[str]:
|
|
36
|
+
return [option_name for option_name, value in options.items() if value in (None, "")]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _coerce_input_file(value: Any) -> Path:
|
|
40
|
+
path = Path(value).expanduser()
|
|
41
|
+
if not path.is_file():
|
|
42
|
+
raise click.ClickException(f"Input file does not exist: {path}")
|
|
43
|
+
return path
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _coerce_positive_int(value: Any, *, field_name: str) -> int:
|
|
47
|
+
try:
|
|
48
|
+
resolved = int(value)
|
|
49
|
+
except (TypeError, ValueError) as exc:
|
|
50
|
+
raise click.ClickException(f"{field_name} must be a positive integer.") from exc
|
|
51
|
+
|
|
52
|
+
if resolved < 1:
|
|
53
|
+
raise click.ClickException(f"{field_name} must be a positive integer.")
|
|
54
|
+
|
|
55
|
+
return resolved
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _coerce_bool(value: Any, *, field_name: str) -> bool:
|
|
59
|
+
if isinstance(value, bool):
|
|
60
|
+
return value
|
|
61
|
+
|
|
62
|
+
raise click.ClickException(f"{field_name} must be a boolean true or false.")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@click.group(context_settings={"help_option_names": ["-h", "--help"]})
|
|
66
|
+
@click.version_option(version=__version__, prog_name="isemass")
|
|
67
|
+
def cli() -> None:
|
|
68
|
+
"""Mass Cisco ISE related operations."""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@cli.command()
|
|
72
|
+
@click.option(
|
|
73
|
+
"--force",
|
|
74
|
+
is_flag=True,
|
|
75
|
+
help="Overwrite an existing settings.toml file.",
|
|
76
|
+
)
|
|
77
|
+
def init(force: bool) -> None:
|
|
78
|
+
"""Create the optional settings.toml file."""
|
|
79
|
+
path, wrote_file = write_default_settings(force=force)
|
|
80
|
+
|
|
81
|
+
if not wrote_file:
|
|
82
|
+
raise click.ClickException(f"Settings file already exists: {path}. Use --force to overwrite.")
|
|
83
|
+
|
|
84
|
+
console.print(f"[green]Created settings file:[/green] '{path}'")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@cli.command()
|
|
88
|
+
@click.option(
|
|
89
|
+
"-i",
|
|
90
|
+
"--input-file",
|
|
91
|
+
type=click.Path(exists=True, dir_okay=False, path_type=Path),
|
|
92
|
+
help="Input text file containing MAC addresses.",
|
|
93
|
+
)
|
|
94
|
+
@click.option(
|
|
95
|
+
"-u",
|
|
96
|
+
"--username",
|
|
97
|
+
help="API username. Prompts if omitted and not configured.",
|
|
98
|
+
)
|
|
99
|
+
@click.option(
|
|
100
|
+
"-w",
|
|
101
|
+
"--max-workers",
|
|
102
|
+
type=int,
|
|
103
|
+
help="Max workers for multitasking at once.",
|
|
104
|
+
)
|
|
105
|
+
@click.option(
|
|
106
|
+
"--host",
|
|
107
|
+
help="FQDN or IP for the API request, typically the MnT node.",
|
|
108
|
+
)
|
|
109
|
+
@click.option(
|
|
110
|
+
"-n",
|
|
111
|
+
"--node",
|
|
112
|
+
help="Short ISE node name that processes the CoA request.",
|
|
113
|
+
)
|
|
114
|
+
@click.option(
|
|
115
|
+
"-k",
|
|
116
|
+
"--insecure",
|
|
117
|
+
is_flag=True,
|
|
118
|
+
default=None,
|
|
119
|
+
help="Skip HTTPS certificate validation.",
|
|
120
|
+
)
|
|
121
|
+
@click.option(
|
|
122
|
+
"-y",
|
|
123
|
+
"--yes",
|
|
124
|
+
is_flag=True,
|
|
125
|
+
help="Skip confirmation before submitting CoA requests.",
|
|
126
|
+
)
|
|
127
|
+
def coa(
|
|
128
|
+
input_file: Path | None,
|
|
129
|
+
username: str | None,
|
|
130
|
+
max_workers: int | None,
|
|
131
|
+
host: str | None,
|
|
132
|
+
node: str | None,
|
|
133
|
+
insecure: bool | None,
|
|
134
|
+
yes: bool,
|
|
135
|
+
) -> None:
|
|
136
|
+
"""ISE Mass CoA through API."""
|
|
137
|
+
settings = _load_settings_for_cli()
|
|
138
|
+
coa_settings = _section(settings, "coa")
|
|
139
|
+
|
|
140
|
+
required_values = {
|
|
141
|
+
"--input-file": _resolve_option(input_file, coa_settings.get("input_file")),
|
|
142
|
+
"--host": _resolve_option(host, coa_settings.get("host")),
|
|
143
|
+
"--node": _resolve_option(node, coa_settings.get("node")),
|
|
144
|
+
}
|
|
145
|
+
missing = _missing_required_options(required_values)
|
|
146
|
+
if missing:
|
|
147
|
+
missing_options = ", ".join(missing)
|
|
148
|
+
raise click.UsageError(
|
|
149
|
+
f"Missing required option(s): {missing_options}. "
|
|
150
|
+
"Provide them on the CLI or in [coa]."
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
resolved_input_file = _coerce_input_file(required_values["--input-file"])
|
|
154
|
+
resolved_host = str(required_values["--host"])
|
|
155
|
+
resolved_node = str(required_values["--node"])
|
|
156
|
+
resolved_username = _resolve_option(username, coa_settings.get("username"))
|
|
157
|
+
if not resolved_username:
|
|
158
|
+
resolved_username = click.prompt("API username", type=str)
|
|
159
|
+
|
|
160
|
+
resolved_max_workers = _coerce_positive_int(
|
|
161
|
+
_resolve_option(max_workers, coa_settings.get("max_workers")),
|
|
162
|
+
field_name="max_workers",
|
|
163
|
+
)
|
|
164
|
+
resolved_insecure = _coerce_bool(
|
|
165
|
+
_resolve_option(insecure, coa_settings.get("insecure")),
|
|
166
|
+
field_name="insecure",
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
password = click.prompt(f"API password for {resolved_username}", hide_input=True, type=str)
|
|
170
|
+
macs = coa_ops.extract_macs_from_file(resolved_input_file)
|
|
171
|
+
if not macs:
|
|
172
|
+
raise click.ClickException(f"No MAC addresses found in {resolved_input_file}.")
|
|
173
|
+
|
|
174
|
+
_print_mac_preview(macs)
|
|
175
|
+
if not yes and not click.confirm("Continue with CoA operation?", default=False):
|
|
176
|
+
raise click.ClickException("Operation not approved. Aborting.")
|
|
177
|
+
|
|
178
|
+
console.print(f"Starting CoA requests for {len(macs)} MAC address(es)...", highlight=False)
|
|
179
|
+
for result in coa_ops.run_coa_requests(
|
|
180
|
+
macs=macs,
|
|
181
|
+
host=resolved_host,
|
|
182
|
+
node=resolved_node,
|
|
183
|
+
username=str(resolved_username),
|
|
184
|
+
password=password,
|
|
185
|
+
max_workers=resolved_max_workers,
|
|
186
|
+
insecure=resolved_insecure,
|
|
187
|
+
):
|
|
188
|
+
_print_coa_result(result)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@cli.command()
|
|
192
|
+
def swauth() -> None:
|
|
193
|
+
"""Mass session reauthentication through switch SSH."""
|
|
194
|
+
settings = _load_settings_for_cli()
|
|
195
|
+
swauth_settings = _section(settings, "swauth")
|
|
196
|
+
verbose = _coerce_bool(swauth_settings.get("verbose", False), field_name="verbose")
|
|
197
|
+
|
|
198
|
+
console.print("[yellow]Switch reauthentication is not implemented yet.[/yellow]")
|
|
199
|
+
console.print(f"Verbose: {verbose}")
|
|
200
|
+
console.print(f"Settings path: {get_settings_path()}")
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _print_mac_preview(macs: list[str]) -> None:
|
|
204
|
+
table = Table(title=f"Unique MAC addresses ({len(macs)})")
|
|
205
|
+
table.add_column("#", justify="right")
|
|
206
|
+
table.add_column("MAC address", style="cyan")
|
|
207
|
+
|
|
208
|
+
for index, mac in enumerate(macs, start=1):
|
|
209
|
+
table.add_row(str(index), mac)
|
|
210
|
+
|
|
211
|
+
console.print(table)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _print_coa_result(result: coa_ops.CoaResult) -> None:
|
|
215
|
+
styles = {
|
|
216
|
+
coa_ops.CoaStatus.SUCCEEDED: "green",
|
|
217
|
+
coa_ops.CoaStatus.COA_FAILED: "yellow",
|
|
218
|
+
coa_ops.CoaStatus.REQUEST_FAILED: "red",
|
|
219
|
+
}
|
|
220
|
+
labels = {
|
|
221
|
+
coa_ops.CoaStatus.SUCCEEDED: "SUCCEEDED",
|
|
222
|
+
coa_ops.CoaStatus.COA_FAILED: "FAILED/UNDETERMINED",
|
|
223
|
+
coa_ops.CoaStatus.REQUEST_FAILED: "REQUEST FAILED",
|
|
224
|
+
}
|
|
225
|
+
style = styles[result.status]
|
|
226
|
+
label = labels[result.status]
|
|
227
|
+
|
|
228
|
+
console.print(
|
|
229
|
+
f"{result.mac} | {result.seconds:>5.2f}s | [{style}]{label}[/{style}] | {result.detail}",
|
|
230
|
+
highlight=False,
|
|
231
|
+
)
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Cisco ISE CoA API helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from enum import StrEnum
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from time import perf_counter
|
|
10
|
+
import re
|
|
11
|
+
from typing import Any, Callable, Iterable
|
|
12
|
+
from xml.etree import ElementTree
|
|
13
|
+
|
|
14
|
+
import requests
|
|
15
|
+
import urllib3
|
|
16
|
+
|
|
17
|
+
HEADERS = {
|
|
18
|
+
"Accept": "application/xml",
|
|
19
|
+
}
|
|
20
|
+
REQUEST_TIMEOUT_SECONDS = 30
|
|
21
|
+
|
|
22
|
+
# REGEX for all formats of MAC address.
|
|
23
|
+
# "?:" removes groups so re.findall returns complete MAC address strings.
|
|
24
|
+
RE_MAC_COLON = r"(?:[0-9A-Fa-f]{2}:){5}(?:[0-9A-Fa-f]{2})"
|
|
25
|
+
RE_MAC_DASH = r"(?:[0-9A-Fa-f]{2}-){5}(?:[0-9A-Fa-f]{2})"
|
|
26
|
+
RE_MAC_CISCO = r"(?:[0-9A-Fa-f]{4}\.){2}(?:[0-9A-Fa-f]{4})"
|
|
27
|
+
RE_MAC_ALL = RE_MAC_COLON + r"|" + RE_MAC_DASH + r"|" + RE_MAC_CISCO
|
|
28
|
+
RE_MAC_PATTERN = re.compile(RE_MAC_ALL)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class CoaStatus(StrEnum):
|
|
32
|
+
"""Normalized CoA request outcome."""
|
|
33
|
+
|
|
34
|
+
REQUEST_FAILED = "request_failed"
|
|
35
|
+
COA_FAILED = "coa_failed"
|
|
36
|
+
SUCCEEDED = "succeeded"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class CoaResult:
|
|
41
|
+
"""Result for one MAC address CoA request."""
|
|
42
|
+
|
|
43
|
+
mac: str
|
|
44
|
+
status: CoaStatus
|
|
45
|
+
seconds: float
|
|
46
|
+
detail: str
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
RequestFunc = Callable[..., Any]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def normalize_mac(mac: str) -> str:
|
|
53
|
+
"""Normalize a supported MAC address string to uppercase colon format."""
|
|
54
|
+
compact = mac.replace(":", "").replace("-", "").replace(".", "").upper()
|
|
55
|
+
return ":".join(compact[index : index + 2] for index in range(0, len(compact), 2))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def extract_macs(text: str) -> list[str]:
|
|
59
|
+
"""Extract unique MAC addresses from text while preserving first-seen order."""
|
|
60
|
+
unique_macs: list[str] = []
|
|
61
|
+
seen: set[str] = set()
|
|
62
|
+
|
|
63
|
+
for match in RE_MAC_PATTERN.findall(text):
|
|
64
|
+
normalized = normalize_mac(match)
|
|
65
|
+
if normalized in seen:
|
|
66
|
+
continue
|
|
67
|
+
seen.add(normalized)
|
|
68
|
+
unique_macs.append(normalized)
|
|
69
|
+
|
|
70
|
+
return unique_macs
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def extract_macs_from_file(path: Path) -> list[str]:
|
|
74
|
+
"""Read text from path and extract unique MAC addresses."""
|
|
75
|
+
return extract_macs(path.read_text(encoding="utf-8"))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def build_coa_url(*, host: str, node: str, mac: str) -> str:
|
|
79
|
+
"""Build the Cisco ISE CoA Reauth API URL."""
|
|
80
|
+
return f"https://{host}/admin/API/mnt/CoA/Reauth/{node}/{mac}/0"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def perform_coa_request(
|
|
84
|
+
*,
|
|
85
|
+
mac: str,
|
|
86
|
+
host: str,
|
|
87
|
+
node: str,
|
|
88
|
+
username: str,
|
|
89
|
+
password: str,
|
|
90
|
+
verify_tls: bool,
|
|
91
|
+
timeout: float = REQUEST_TIMEOUT_SECONDS,
|
|
92
|
+
request_func: RequestFunc = requests.get,
|
|
93
|
+
) -> CoaResult:
|
|
94
|
+
"""Perform one Cisco ISE CoA request and classify the result."""
|
|
95
|
+
url = build_coa_url(host=host, node=node, mac=mac)
|
|
96
|
+
start = perf_counter()
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
response = request_func(
|
|
100
|
+
url,
|
|
101
|
+
headers=HEADERS,
|
|
102
|
+
verify=verify_tls,
|
|
103
|
+
auth=(username, password),
|
|
104
|
+
timeout=timeout,
|
|
105
|
+
)
|
|
106
|
+
except requests.RequestException as exc:
|
|
107
|
+
return CoaResult(
|
|
108
|
+
mac=mac,
|
|
109
|
+
status=CoaStatus.REQUEST_FAILED,
|
|
110
|
+
seconds=_elapsed_seconds(start),
|
|
111
|
+
detail=f"API request failed: {exc}",
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
# TEMP TESTING. MIGHT BE USED FOR VERBOSITY LATER
|
|
115
|
+
# print(response.status_code, response.headers, response.text)
|
|
116
|
+
|
|
117
|
+
seconds = _elapsed_seconds(start)
|
|
118
|
+
status_code = getattr(response, "status_code", None)
|
|
119
|
+
if status_code is None or not 200 <= status_code < 300:
|
|
120
|
+
reason = getattr(response, "reason", "")
|
|
121
|
+
reason_text = f" {reason}" if reason else ""
|
|
122
|
+
return CoaResult(
|
|
123
|
+
mac=mac,
|
|
124
|
+
status=CoaStatus.REQUEST_FAILED,
|
|
125
|
+
seconds=seconds,
|
|
126
|
+
detail=f"HTTP {status_code}{reason_text}",
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
result_value = _extract_remote_coa_result(getattr(response, "text", ""))
|
|
130
|
+
if result_value is None:
|
|
131
|
+
return CoaResult(
|
|
132
|
+
mac=mac,
|
|
133
|
+
status=CoaStatus.COA_FAILED,
|
|
134
|
+
seconds=seconds,
|
|
135
|
+
detail="ISE response did not include remoteCoA.results",
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
if result_value.casefold() == "true":
|
|
139
|
+
return CoaResult(
|
|
140
|
+
mac=mac,
|
|
141
|
+
status=CoaStatus.SUCCEEDED,
|
|
142
|
+
seconds=seconds,
|
|
143
|
+
detail="ISE remoteCoA.results=true",
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
return CoaResult(
|
|
147
|
+
mac=mac,
|
|
148
|
+
status=CoaStatus.COA_FAILED,
|
|
149
|
+
seconds=seconds,
|
|
150
|
+
detail=f"ISE remoteCoA.results={result_value}",
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def run_coa_requests(
|
|
155
|
+
*,
|
|
156
|
+
macs: Iterable[str],
|
|
157
|
+
host: str,
|
|
158
|
+
node: str,
|
|
159
|
+
username: str,
|
|
160
|
+
password: str,
|
|
161
|
+
max_workers: int,
|
|
162
|
+
insecure: bool,
|
|
163
|
+
) -> Iterable[CoaResult]:
|
|
164
|
+
"""Run CoA requests concurrently and yield results as they complete."""
|
|
165
|
+
verify_tls = not insecure
|
|
166
|
+
if insecure:
|
|
167
|
+
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
|
168
|
+
|
|
169
|
+
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
|
170
|
+
future_to_mac = {
|
|
171
|
+
executor.submit(
|
|
172
|
+
perform_coa_request,
|
|
173
|
+
mac=mac,
|
|
174
|
+
host=host,
|
|
175
|
+
node=node,
|
|
176
|
+
username=username,
|
|
177
|
+
password=password,
|
|
178
|
+
verify_tls=verify_tls,
|
|
179
|
+
): mac
|
|
180
|
+
for mac in macs
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
for future in as_completed(future_to_mac):
|
|
184
|
+
mac = future_to_mac[future]
|
|
185
|
+
try:
|
|
186
|
+
yield future.result()
|
|
187
|
+
except Exception as exc:
|
|
188
|
+
yield CoaResult(
|
|
189
|
+
mac=mac,
|
|
190
|
+
status=CoaStatus.REQUEST_FAILED,
|
|
191
|
+
seconds=0.0,
|
|
192
|
+
detail=f"Unexpected worker error: {exc}",
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _elapsed_seconds(start: float) -> float:
|
|
197
|
+
return round(perf_counter() - start, 2)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _extract_remote_coa_result(xml_text: str) -> str | None:
|
|
201
|
+
try:
|
|
202
|
+
root = ElementTree.fromstring(xml_text)
|
|
203
|
+
except ElementTree.ParseError:
|
|
204
|
+
return None
|
|
205
|
+
|
|
206
|
+
remote_coa = _find_first_element(root, "remoteCoA")
|
|
207
|
+
if remote_coa is None:
|
|
208
|
+
return None
|
|
209
|
+
|
|
210
|
+
results = _find_first_element(remote_coa, "results")
|
|
211
|
+
if results is None or results.text is None:
|
|
212
|
+
return None
|
|
213
|
+
|
|
214
|
+
return results.text.strip()
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _find_first_element(root: ElementTree.Element, local_name: str) -> ElementTree.Element | None:
|
|
218
|
+
for element in root.iter():
|
|
219
|
+
if _local_name(element.tag) == local_name:
|
|
220
|
+
return element
|
|
221
|
+
return None
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _local_name(tag: str) -> str:
|
|
225
|
+
return tag.rsplit("}", maxsplit=1)[-1]
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Optional settings.toml support for isemass."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
import tomllib
|
|
7
|
+
from importlib.resources import files
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from platformdirs import user_config_dir
|
|
12
|
+
|
|
13
|
+
from isemass.defaults import DEFAULT_SETTINGS
|
|
14
|
+
|
|
15
|
+
APP_NAME = "isemass"
|
|
16
|
+
SETTINGS_FILENAME = "settings.toml"
|
|
17
|
+
TEMPLATE_PACKAGE = "isemass.templates"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SettingsError(Exception):
|
|
21
|
+
"""Raised when settings.toml cannot be loaded safely."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_config_dir() -> Path:
|
|
25
|
+
"""Return the platform-specific user config directory."""
|
|
26
|
+
return Path(user_config_dir(APP_NAME))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def get_settings_path() -> Path:
|
|
30
|
+
"""Return the platform-specific settings.toml path."""
|
|
31
|
+
return get_config_dir() / SETTINGS_FILENAME
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def default_settings() -> dict[str, dict[str, Any]]:
|
|
35
|
+
"""Return a copy of the built-in settings defaults."""
|
|
36
|
+
return copy.deepcopy(DEFAULT_SETTINGS)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def load_settings() -> dict[str, dict[str, Any]]:
|
|
40
|
+
"""Load settings.toml and merge it over built-in defaults."""
|
|
41
|
+
path = get_settings_path()
|
|
42
|
+
settings = default_settings()
|
|
43
|
+
|
|
44
|
+
if not path.exists():
|
|
45
|
+
return settings
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
parsed = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
49
|
+
except tomllib.TOMLDecodeError as exc:
|
|
50
|
+
raise SettingsError(f"Invalid TOML in {path}: {exc}") from exc
|
|
51
|
+
except OSError as exc:
|
|
52
|
+
raise SettingsError(f"Unable to read settings file {path}: {exc}") from exc
|
|
53
|
+
|
|
54
|
+
for section_name, section_values in parsed.items():
|
|
55
|
+
if not isinstance(section_values, dict):
|
|
56
|
+
raise SettingsError(
|
|
57
|
+
f"Invalid settings in {path}: top-level key '{section_name}' must be a table."
|
|
58
|
+
)
|
|
59
|
+
settings.setdefault(section_name, {}).update(section_values)
|
|
60
|
+
|
|
61
|
+
return settings
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def load_settings_template() -> str:
|
|
65
|
+
"""Return the packaged default settings.toml template."""
|
|
66
|
+
return files(TEMPLATE_PACKAGE).joinpath(SETTINGS_FILENAME).read_text(encoding="utf-8")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def write_default_settings(*, force: bool = False) -> tuple[Path, bool]:
|
|
70
|
+
"""Write the default settings template.
|
|
71
|
+
|
|
72
|
+
Returns the settings path and a boolean indicating whether a file was written.
|
|
73
|
+
"""
|
|
74
|
+
path = get_settings_path()
|
|
75
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
76
|
+
|
|
77
|
+
if path.exists() and not force:
|
|
78
|
+
return path, False
|
|
79
|
+
|
|
80
|
+
path.write_text(load_settings_template(), encoding="utf-8")
|
|
81
|
+
return path, True
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Built-in settings defaults."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
DEFAULT_SETTINGS: dict[str, dict[str, Any]] = {
|
|
8
|
+
"coa": {
|
|
9
|
+
"verbose": False,
|
|
10
|
+
"max_workers": 20,
|
|
11
|
+
"insecure": False,
|
|
12
|
+
},
|
|
13
|
+
"swauth": {
|
|
14
|
+
"verbose": False,
|
|
15
|
+
},
|
|
16
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[coa]
|
|
2
|
+
# Turns on extra verbose logging for CoA operations.
|
|
3
|
+
verbose = false
|
|
4
|
+
|
|
5
|
+
# Optional API username. Passwords/tokens are intentionally not stored here.
|
|
6
|
+
# username = "bob-example"
|
|
7
|
+
|
|
8
|
+
# Input text file containing MAC addresses. CLI equivalent: -i/--input-file.
|
|
9
|
+
# input_file = "macs.txt"
|
|
10
|
+
|
|
11
|
+
# API host to connect to, typically the MnT node FQDN or IP.
|
|
12
|
+
# host = "ise-mnt.example.com"
|
|
13
|
+
|
|
14
|
+
# Short ISE node name that processes the CoA request, typically a PSN.
|
|
15
|
+
# node = "ise-psn01"
|
|
16
|
+
|
|
17
|
+
# Maximum number of concurrent workers for CoA tasks.
|
|
18
|
+
max_workers = 20
|
|
19
|
+
|
|
20
|
+
# Skip HTTPS certificate validation when set to true.
|
|
21
|
+
insecure = false
|
|
22
|
+
|
|
23
|
+
[swauth]
|
|
24
|
+
# Turns on extra verbose logging for switch reauthentication operations.
|
|
25
|
+
verbose = false
|