centralcli 7.2.1__tar.gz → 7.2.4__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.
- {centralcli-7.2.1 → centralcli-7.2.4}/PKG-INFO +1 -1
- {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/__init__.py +10 -9
- {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/cache.py +43 -31
- {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/cleaner.py +1 -1
- {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/clicommon.py +16 -14
- {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/clikick.py +27 -12
- {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/clishow.py +44 -4
- {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/clitest.py +1 -1
- {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/cliupdate.py +19 -19
- {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/models.py +1 -1
- {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/response.py +2 -1
- {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/strings.py +7 -0
- {centralcli-7.2.1 → centralcli-7.2.4}/pyproject.toml +1 -1
- {centralcli-7.2.1 → centralcli-7.2.4}/LICENSE +0 -0
- {centralcli-7.2.1 → centralcli-7.2.4}/README.md +0 -0
- {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/boilerplate/README.md +0 -0
- {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/boilerplate/_cnx_allcalls.py +0 -0
- {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/boilerplate/allcalls.py +0 -0
- {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/caas.py +0 -0
- {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/central.py +0 -0
- {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/cli.py +0 -0
- {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/cliadd.py +0 -0
- {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/cliassign.py +0 -0
- {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/clibatch.py +0 -0
- {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/clicaas.py +0 -0
- {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/clicheck.py +0 -0
- {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/cliclone.py +0 -0
- {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/clidel.py +0 -0
- {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/clidelfirmware.py +0 -0
- {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/cliexport.py +0 -0
- {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/clioptions.py +0 -0
- {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/clirefresh.py +0 -0
- {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/clirename.py +0 -0
- {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/cliset.py +0 -0
- {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/clisetfirmware.py +0 -0
- {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/clishowaudit.py +0 -0
- {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/clishowbandwidth.py +0 -0
- {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/clishowbranch.py +0 -0
- {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/clishowcloudauth.py +0 -0
- {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/clishowfirmware.py +0 -0
- {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/clishowmpsk.py +0 -0
- {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/clishowospf.py +0 -0
- {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/clishowoverlay.py +0 -0
- {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/clishowtshoot.py +0 -0
- {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/clishowwids.py +0 -0
- {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/clitshoot.py +0 -0
- {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/cliunassign.py +0 -0
- {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/cliupgrade.py +0 -0
- {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/config.py +0 -0
- {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/constants.py +0 -0
- {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/exceptions.py +0 -0
- {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/logger.py +0 -0
- {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/objects.py +0 -0
- {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/render.py +0 -0
- {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/setup.py +0 -0
- {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/static/favicon.ico +0 -0
- {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/typedefs.py +0 -0
- {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/utils.py +0 -0
- {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/vendored/csvlexer/__init__.py +0 -0
- {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/vendored/csvlexer/csv.py +0 -0
- {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/vscodeargs.py +0 -0
- {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/wh2snow.py +0 -0
- {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/wh_proxy.py +0 -0
- {centralcli-7.2.1 → centralcli-7.2.4}/centralcli/wh_proxy_service.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: centralcli
|
|
3
|
-
Version: 7.2.
|
|
3
|
+
Version: 7.2.4
|
|
4
4
|
Summary: A CLI for interacting with Aruba Central (Cloud Management Platform). Facilitates bulk imports, exports, reporting. A handy tool if you have devices managed by Aruba Central.
|
|
5
5
|
Home-page: https://github.com/Pack3tL0ss/central-api-cli
|
|
6
6
|
License: MIT
|
|
@@ -194,15 +194,16 @@ if "--capture-raw" in sys.argv: # captures raw responses into a flat file for l
|
|
|
194
194
|
central = CentralApi(config.account)
|
|
195
195
|
Cache.set_config(config)
|
|
196
196
|
cache = Cache(central)
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
197
|
+
if config.valid:
|
|
198
|
+
CacheDevice.set_db(cache.DevDB)
|
|
199
|
+
CacheInvDevice.set_db(cache.InvDB)
|
|
200
|
+
CacheGroup.set_db(cache.GroupDB)
|
|
201
|
+
CacheSite.set_db(cache.SiteDB)
|
|
202
|
+
CacheClient.set_db(cache.ClientDB, cache=cache)
|
|
203
|
+
CacheLabel.set_db(cache.LabelDB)
|
|
204
|
+
CachePortal.set_db(cache.PortalDB)
|
|
205
|
+
CacheTemplate.set_db(cache.TemplateDB)
|
|
206
|
+
CacheMpskNetwork.set_db(cache.MpskDB)
|
|
206
207
|
cli = CLICommon(config.account, cache, central, raw_out=raw_out)
|
|
207
208
|
|
|
208
209
|
# allow singular form and common synonyms for the defined show commands
|
|
@@ -464,6 +464,10 @@ class CachePortal(CentralObject):
|
|
|
464
464
|
def __rich__(self) -> str:
|
|
465
465
|
return f'[bright_green]Portal Profile[/]:[bright_green]{self.name}[/]|[cyan]{self.id}[/]'
|
|
466
466
|
|
|
467
|
+
@property
|
|
468
|
+
def help_text(self):
|
|
469
|
+
return render.rich_capture(self.__rich__())
|
|
470
|
+
|
|
467
471
|
|
|
468
472
|
class CacheTemplate(CentralObject):
|
|
469
473
|
db: Table | None = None
|
|
@@ -560,6 +564,12 @@ class CacheClient(CentralObject):
|
|
|
560
564
|
def __rich__(self) -> str:
|
|
561
565
|
return f'[bright_green]Client[/]:[cyan]{self.name}[/]|({utils.color([self.type, self.ip, self.mac, self.connected_name], "green_yellow", sep="|")}|s[green_yellow]{self.site})[/]'
|
|
562
566
|
|
|
567
|
+
@property
|
|
568
|
+
def help_text(self) -> str:
|
|
569
|
+
return render.rich_capture(
|
|
570
|
+
f"[bright_green]{self.name}[/]|[cyan]{self.mac}[/]|[bright_green]{self.ip}[/]|[cyan]{f's:{self.site}' if self.site else f'g:{self.group}'}[/]|[dark_olive_green2]{self.connected_name}[/]"
|
|
571
|
+
)
|
|
572
|
+
|
|
563
573
|
|
|
564
574
|
class CacheMpskNetwork(CentralObject):
|
|
565
575
|
db: Table | None = None
|
|
@@ -1349,9 +1359,9 @@ class Cache:
|
|
|
1349
1359
|
match = [m for m in match if m.name not in args]
|
|
1350
1360
|
for m in sorted(match, key=lambda i: i.name):
|
|
1351
1361
|
if m.name.startswith(incomplete):
|
|
1352
|
-
out += [tuple([m.name, m.
|
|
1362
|
+
out += [tuple([m.name, m.help_text])]
|
|
1353
1363
|
elif m.id.startswith(incomplete):
|
|
1354
|
-
out += [tuple([m.id, m.
|
|
1364
|
+
out += [tuple([m.id, m.help_text])]
|
|
1355
1365
|
else:
|
|
1356
1366
|
out += [tuple([m.name, m.help_text])] # failsafe, shouldn't hit
|
|
1357
1367
|
|
|
@@ -1959,19 +1969,18 @@ class Cache:
|
|
|
1959
1969
|
# remove clients that are already on the command line
|
|
1960
1970
|
match = [m for m in match if m.name not in args]
|
|
1961
1971
|
for c in sorted(match, key=lambda i: i.name):
|
|
1962
|
-
if c.name.startswith(incomplete):
|
|
1963
|
-
out += c.help_text
|
|
1972
|
+
if c.name.lower().startswith(incomplete.lower()):
|
|
1973
|
+
out += [(c.name, c.help_text)]
|
|
1964
1974
|
elif c.mac.strip(":.-").lower().startswith(incomplete.strip(":.-")):
|
|
1965
|
-
out += c.help_text
|
|
1975
|
+
out += [(c.mac, c.help_text)]
|
|
1966
1976
|
elif c.ip.startswith(incomplete):
|
|
1967
|
-
out += c.help_text
|
|
1977
|
+
out += [(c.ip, c.help_text)]
|
|
1968
1978
|
else:
|
|
1969
1979
|
# failsafe, shouldn't hit
|
|
1970
|
-
out += (c.
|
|
1980
|
+
out += [(c.name, f'{c.help_text} FailSafe Match')]
|
|
1971
1981
|
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
yield c[0].replace(":", "-"), c[1]
|
|
1982
|
+
for c in out:
|
|
1983
|
+
yield c[0].replace(":", "-"), c[1] # TODO completion behavior has changed. This works-around issue bash doesn't complete past 00: and zsh treats each octet as a dev name when : is used.
|
|
1975
1984
|
|
|
1976
1985
|
def event_log_completion(
|
|
1977
1986
|
self,
|
|
@@ -3218,27 +3227,30 @@ class Cache:
|
|
|
3218
3227
|
|
|
3219
3228
|
|
|
3220
3229
|
# no match found initiate cache update
|
|
3221
|
-
if retry and not match and
|
|
3222
|
-
|
|
3223
|
-
|
|
3224
|
-
|
|
3225
|
-
if dev_type:
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
|
|
3232
|
-
|
|
3233
|
-
|
|
3234
|
-
|
|
3235
|
-
|
|
3236
|
-
kwargs
|
|
3237
|
-
|
|
3238
|
-
|
|
3239
|
-
|
|
3240
|
-
|
|
3241
|
-
|
|
3230
|
+
if retry and not match and self.responses.dev is None:
|
|
3231
|
+
if dev_type and cache_updated:
|
|
3232
|
+
... # self.responses.dev is not currently updated if dev_type provided, but cache update may have already occured in this session.
|
|
3233
|
+
else:
|
|
3234
|
+
dev_type_sfx = "" if not dev_type else f" [grey42 italic](Device Type: {utils.unlistify(dev_type)})[/]"
|
|
3235
|
+
econsole.print(f"[dark_orange3]:warning:[/] [bright_red]No Match found[/] for [cyan]{query_str}[/]{dev_type_sfx}.")
|
|
3236
|
+
if FUZZ:
|
|
3237
|
+
if dev_type:
|
|
3238
|
+
fuzz_match, fuzz_confidence = process.extract(query_str, [d["name"] for d in self.devices if d["type"] in dev_type], limit=1)[0]
|
|
3239
|
+
else:
|
|
3240
|
+
fuzz_match, fuzz_confidence = process.extract(query_str, [d["name"] for d in self.devices], limit=1)[0]
|
|
3241
|
+
confirm_str = render.rich_capture(f"Did you mean [green3]{fuzz_match}[/]?")
|
|
3242
|
+
if fuzz_confidence >= 70 and typer.confirm(confirm_str):
|
|
3243
|
+
match = self.DevDB.search(self.Q.name == fuzz_match)
|
|
3244
|
+
if not match:
|
|
3245
|
+
kwargs = {"dev_db": True}
|
|
3246
|
+
if include_inventory:
|
|
3247
|
+
_word = " & Inventory "
|
|
3248
|
+
kwargs["inv_db"] = True
|
|
3249
|
+
else:
|
|
3250
|
+
_word = " "
|
|
3251
|
+
econsole.print(f":arrows_clockwise: Updating Device{_word}Cache.")
|
|
3252
|
+
self.check_fresh(refresh=True, dev_type=dev_type, **kwargs )
|
|
3253
|
+
cache_updated = True # Need this for scenario when dev_type is the only thing refreshed, as that does not update self.responses.dev
|
|
3242
3254
|
|
|
3243
3255
|
if match:
|
|
3244
3256
|
match = [Model(dev) for dev in match]
|
|
@@ -1626,7 +1626,7 @@ def show_interfaces(data: List[dict] | dict, verbosity: int = 0, dev_type: DevTy
|
|
|
1626
1626
|
key_order = verbosity_keys[verbosity]
|
|
1627
1627
|
# send all key/value pairs through formatters
|
|
1628
1628
|
data = [
|
|
1629
|
-
dict(short_value(k, d
|
|
1629
|
+
dict(short_value(k, d[k],) for k in key_order if k in d) for d in data
|
|
1630
1630
|
]
|
|
1631
1631
|
else:
|
|
1632
1632
|
key_order = [*key_order, *data[-1].keys()]
|
|
@@ -383,8 +383,7 @@ class CLICommon:
|
|
|
383
383
|
else:
|
|
384
384
|
return default
|
|
385
385
|
|
|
386
|
-
|
|
387
|
-
def write_file(outfile: Path, outdata: str) -> None:
|
|
386
|
+
def write_file(self, outfile: Path, outdata: str) -> None:
|
|
388
387
|
"""Output data to file
|
|
389
388
|
|
|
390
389
|
Args:
|
|
@@ -407,19 +406,22 @@ class CLICommon:
|
|
|
407
406
|
|
|
408
407
|
print(f"\n[cyan]Writing output to {outfile}... ", end="")
|
|
409
408
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
409
|
+
if not outfile.parent.is_dir():
|
|
410
|
+
self.econsole.print(f"[red]Directory Not Found[/]\n[dark_orange3]:warning:[/] Unable to write output to [cyan]{outfile.name}[/].\nDirectory [cyan]{str(outfile.parent.absolute())}[/] [red]does not exist[/].")
|
|
411
|
+
else:
|
|
412
|
+
out_msg = None
|
|
413
|
+
try:
|
|
414
|
+
if isinstance(outdata, (dict, list)):
|
|
415
|
+
outdata = json.dumps(outdata, indent=4)
|
|
416
|
+
outfile.write_text(outdata) # typer.unstyle(outdata) also works
|
|
417
|
+
except Exception as e:
|
|
418
|
+
outfile.write_text(f"{outdata}")
|
|
419
|
+
out_msg = f"Error ({e.__class__.__name__}) occurred during attempt to output to file. " \
|
|
420
|
+
"Used simple string conversion"
|
|
419
421
|
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
422
|
+
print("[italic green]Done")
|
|
423
|
+
if out_msg:
|
|
424
|
+
log.warning(out_msg, show=True)
|
|
423
425
|
|
|
424
426
|
@staticmethod
|
|
425
427
|
def exit(msg: str = None, code: int = 1, emoji: bool = True) -> None:
|
|
@@ -8,7 +8,6 @@ from rich import print
|
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
# Detect if called from pypi installed package or via cloned github repo (development)
|
|
11
|
-
# TODO should be able to do this in __init__
|
|
12
11
|
try:
|
|
13
12
|
from centralcli import cli
|
|
14
13
|
except (ImportError, ModuleNotFoundError) as e:
|
|
@@ -19,16 +18,18 @@ except (ImportError, ModuleNotFoundError) as e:
|
|
|
19
18
|
else:
|
|
20
19
|
print(pkg_dir.parts)
|
|
21
20
|
raise e
|
|
22
|
-
from centralcli.constants import IdenMetaVars
|
|
23
21
|
|
|
24
|
-
|
|
22
|
+
from centralcli.constants import iden_meta
|
|
23
|
+
from .cache import CacheClient
|
|
24
|
+
from .models import Clients
|
|
25
|
+
|
|
25
26
|
app = typer.Typer()
|
|
26
27
|
|
|
27
28
|
@app.command(short_help="Disconnect all WLAN clients from an AP optionally for a specific SSID",)
|
|
28
29
|
def all(
|
|
29
30
|
device: str = typer.Argument(
|
|
30
31
|
...,
|
|
31
|
-
metavar=
|
|
32
|
+
metavar=iden_meta.dev,
|
|
32
33
|
help="The AP to disconnect clients from",
|
|
33
34
|
autocompletion=cli.cache.dev_ap_completion,
|
|
34
35
|
show_default=False,
|
|
@@ -46,7 +47,7 @@ def all(
|
|
|
46
47
|
dev = cli.cache.get_dev_identifier(device)
|
|
47
48
|
_ssid_msg = "" if not ssid else f" on SSID [cyan]{ssid}[/]"
|
|
48
49
|
print(f'Kick [bright_red]ALL[/] users connected to [cyan]{dev.name}[/]{_ssid_msg}')
|
|
49
|
-
if
|
|
50
|
+
if cli.confirm(yes):
|
|
50
51
|
resp = cli.central.request(
|
|
51
52
|
cli.central.kick_users,
|
|
52
53
|
dev.serial,
|
|
@@ -56,12 +57,10 @@ def all(
|
|
|
56
57
|
cli.display_results(resp, tablefmt="action")
|
|
57
58
|
|
|
58
59
|
|
|
59
|
-
# TODO rather than drop option have cache remove users with last_connected > 30 days
|
|
60
60
|
@app.command(short_help="Disconnect a WLAN client",)
|
|
61
61
|
def client(
|
|
62
|
-
client: str = typer.Argument(..., metavar=
|
|
62
|
+
client: str = typer.Argument(..., metavar=iden_meta.client, autocompletion=cli.cache.client_completion, show_default=False),
|
|
63
63
|
refresh: bool = typer.Option(False, "--refresh", "-R", help="Cache is used to determine what AP the client is connected to, which could be [red]stale[/]. This forces a cache update."),
|
|
64
|
-
drop: bool = typer.Option(False, "--drop", "-D", help="(implies -R): Drop all users from existing cache, then refresh. By default any user that has ever connected is retained in the cache."),
|
|
65
64
|
yes: bool = cli.options.yes,
|
|
66
65
|
debug: bool = cli.options.debug,
|
|
67
66
|
default: bool = cli.options.default,
|
|
@@ -75,15 +74,31 @@ def client(
|
|
|
75
74
|
|
|
76
75
|
The [cyan]-R[/] flag can be used to force a cache refresh prior to performing the disconnect.
|
|
77
76
|
"""
|
|
78
|
-
if refresh
|
|
79
|
-
resp = cli.central.request(cli.cache.refresh_client_db, "wireless"
|
|
77
|
+
if refresh:
|
|
78
|
+
resp = cli.central.request(cli.cache.refresh_client_db, client_type="wireless")
|
|
80
79
|
if not resp:
|
|
81
80
|
cli.display_results(resp, exit_on_fail=True)
|
|
82
81
|
|
|
83
|
-
client = cli.cache.get_client_identifier(client, exit_on_fail=True)
|
|
82
|
+
client: CacheClient = cli.cache.get_client_identifier(client, exit_on_fail=True)
|
|
83
|
+
if not client.last_connected:
|
|
84
|
+
if refresh:
|
|
85
|
+
cli.exit(f"Client {client} is not connected.")
|
|
86
|
+
else:
|
|
87
|
+
client_resp = cli.central.request(cli.cache.refresh_client_db, mac=client.mac)
|
|
88
|
+
if not client_resp:
|
|
89
|
+
cli.econsole.print(f"client {client} is not online according to cache, Failure occured attempting to fetch client details from API.")
|
|
90
|
+
cli.display_results(client_resp, exit_on_fail=True)
|
|
91
|
+
|
|
92
|
+
_clients = [CacheClient(c) for c in Clients(client_resp.output)]
|
|
93
|
+
online_client = [c for c in _clients if c.last_connected is not None]
|
|
94
|
+
if online_client:
|
|
95
|
+
client = online_client[-1]
|
|
96
|
+
else:
|
|
97
|
+
client = _clients[-1]
|
|
98
|
+
cli.exit(f"Client {client} is not online: Failure Stage: {client_resp.output[-1].get('failure_stage', '')}, Reason: {client_resp.output[-1].get('failure_reason', '')}")
|
|
84
99
|
|
|
85
100
|
print(f'Kick client [cyan]{client.name}[/], currently connected to [cyan]{client.connected_name}[/]')
|
|
86
|
-
if
|
|
101
|
+
if cli.confirm(yes):
|
|
87
102
|
resp = cli.central.request(
|
|
88
103
|
cli.central.kick_users,
|
|
89
104
|
client.connected_serial,
|
|
@@ -10,6 +10,8 @@ import os
|
|
|
10
10
|
from datetime import datetime
|
|
11
11
|
from typing import List, Iterable, Literal, Dict, Any, Tuple, TYPE_CHECKING
|
|
12
12
|
from pathlib import Path
|
|
13
|
+
import getpass
|
|
14
|
+
from jinja2 import Template
|
|
13
15
|
from rich import print
|
|
14
16
|
from rich.console import Console
|
|
15
17
|
|
|
@@ -48,6 +50,7 @@ from centralcli.constants import (
|
|
|
48
50
|
)
|
|
49
51
|
from centralcli.cache import CentralObject
|
|
50
52
|
from centralcli.objects import DateTime
|
|
53
|
+
from .strings import cron_weekly
|
|
51
54
|
|
|
52
55
|
if TYPE_CHECKING:
|
|
53
56
|
from .cache import CacheSite, CacheGroup, CacheLabel, CacheDevice
|
|
@@ -163,7 +166,7 @@ def _build_device_caption(resp: Response, *, inventory: bool = False, dev_type:
|
|
|
163
166
|
|
|
164
167
|
# Put together counts caption string
|
|
165
168
|
if status:
|
|
166
|
-
_cnt_str = ", ".join([f'[{"bright_green" if status.lower() == "up" else "red"}]{t}[/]: [cyan]{status_by_type[t]["total"]}[/]' for t in status_by_type])
|
|
169
|
+
_cnt_str = ", ".join([f'[{"bright_green" if status.lower() == "up" else "red"}]{status.capitalize()} {t if t != "ap" else "APs"}[/]: [cyan]{status_by_type[t]["total"]}[/]' for t in status_by_type])
|
|
167
170
|
elif inventory:
|
|
168
171
|
_cnt_str = f"Total in inventory: [cyan]{len(resp.output)}[/], "
|
|
169
172
|
_cnt_str = _cnt_str + ", ".join(
|
|
@@ -558,7 +561,7 @@ def aps(
|
|
|
558
561
|
if up and down:
|
|
559
562
|
... # They used both flags. ignore
|
|
560
563
|
elif up or down:
|
|
561
|
-
status = "
|
|
564
|
+
status = "down" if down else "up"
|
|
562
565
|
|
|
563
566
|
show_devices(
|
|
564
567
|
aps, dev_type="ap", include_inventory=with_inv, verbosity=verbose, outfile=outfile, update_cache=update_cache, group=group, site=site, label=label, status=status,
|
|
@@ -2009,6 +2012,7 @@ def wlans(
|
|
|
2009
2012
|
"calculate_client_count": True,
|
|
2010
2013
|
}
|
|
2011
2014
|
|
|
2015
|
+
# TODO specifying WLAN name ... is ignored if verbose
|
|
2012
2016
|
tablefmt = cli.get_format(do_json=do_json, do_yaml=do_yaml, do_csv=do_csv, do_table=do_table, default="rich")
|
|
2013
2017
|
if group: # Specifying the group implies verbose (same # of API calls either way.)
|
|
2014
2018
|
resp = central.request(central.get_full_wlan_list, group)
|
|
@@ -2032,7 +2036,7 @@ def wlans(
|
|
|
2032
2036
|
else:
|
|
2033
2037
|
resp = central.request(central.get_wlans, **params)
|
|
2034
2038
|
caption = None
|
|
2035
|
-
if resp:
|
|
2039
|
+
if resp and not name:
|
|
2036
2040
|
caption = [f'[green]{len(resp.output)}[/] SSIDs, [green]{sum([wlan.get("client_count", 0) for wlan in resp.output])}[/] Wireless Clients.']
|
|
2037
2041
|
caption += ["Summary Output, Specify the group ([cyan]--group GROUP[/])", "or use the verbose flag ([cyan]`-v`[/]) for additional details"]
|
|
2038
2042
|
cli.display_results(resp, tablefmt=tablefmt, title=title, caption=caption, pager=pager, outfile=outfile, sort_by=sort_by, reverse=reverse, cleaner=cleaner.get_wlans)
|
|
@@ -2875,7 +2879,7 @@ def portals(
|
|
|
2875
2879
|
# TODO add sort_by completion, portal completion
|
|
2876
2880
|
@app.command()
|
|
2877
2881
|
def guests(
|
|
2878
|
-
portal: str = typer.Argument(..., help="portal name", show_default=False,),
|
|
2882
|
+
portal: str = typer.Argument(..., help="portal name", autocompletion=cli.cache.portal_completion, show_default=False,),
|
|
2879
2883
|
sort_by: str = cli.options.sort_by,
|
|
2880
2884
|
reverse: bool = cli.options.reverse,
|
|
2881
2885
|
do_json: bool = cli.options.do_json,
|
|
@@ -3017,6 +3021,42 @@ def version(
|
|
|
3017
3021
|
"""
|
|
3018
3022
|
cli.version_callback()
|
|
3019
3023
|
|
|
3024
|
+
@app.command(hidden=os.name != "posix")
|
|
3025
|
+
def cron(
|
|
3026
|
+
accounts: List[str] = typer.Argument(None,),
|
|
3027
|
+
) -> None:
|
|
3028
|
+
"""Show contents of cron file that can be used to automate token refresh weekly.
|
|
3029
|
+
|
|
3030
|
+
This will keep the tokens valid, even if cencli is not used.
|
|
3031
|
+
"""
|
|
3032
|
+
if os.name != "posix":
|
|
3033
|
+
cli.econsole.print("This command is currently only supported on Linux using cron. It is possible to do the same via Windows Task Scheduler. Showing Linux cron.weekly output for reference.")
|
|
3034
|
+
|
|
3035
|
+
user = getpass.getuser()
|
|
3036
|
+
exec_path = sys.argv[0]
|
|
3037
|
+
py_path = sys.executable
|
|
3038
|
+
|
|
3039
|
+
config_data = {
|
|
3040
|
+
"user": user,
|
|
3041
|
+
"py_path": py_path,
|
|
3042
|
+
"exec_path": exec_path,
|
|
3043
|
+
"accounts": "" if not accounts else " ".join(accounts)
|
|
3044
|
+
}
|
|
3045
|
+
|
|
3046
|
+
template = Template(cron_weekly)
|
|
3047
|
+
config_out = template.render(config_data)
|
|
3048
|
+
|
|
3049
|
+
cli.econsole.rule("/etc/cron.weekly/cencli file contents")
|
|
3050
|
+
cli.console.print(config_out)
|
|
3051
|
+
cli.econsole.rule()
|
|
3052
|
+
|
|
3053
|
+
cli.econsole.print(
|
|
3054
|
+
"Place the above contents into a file: /etc/cron.weekly/cencli [grey42 italic](requires sudo)[/]\n"
|
|
3055
|
+
f"Alternatively you can pipe the output directly [cyan]cencli show cron {'' if not accounts else ' '.join(accounts)} | sudo tee /etc/cron.weekly/cencli[/]"
|
|
3056
|
+
"\nThen make it executable: [cyan]sudo chmod +x /etc/cron.weekly/cencli[/]"
|
|
3057
|
+
"\n\n[cyan]cencli refresh token[/] [dark_olive_green2 italic]command will always update the tokens for the default workspace (that's the -d flag)[/]"
|
|
3058
|
+
)
|
|
3059
|
+
|
|
3020
3060
|
|
|
3021
3061
|
def _get_cencli_config() -> None:
|
|
3022
3062
|
try:
|
|
@@ -167,7 +167,7 @@ def method(
|
|
|
167
167
|
resp = _check_bool_to_str(args, kwargs, resp_str=str(e))
|
|
168
168
|
|
|
169
169
|
attrs = {
|
|
170
|
-
k: v for k, v in resp.__dict__.items() if k not in ["output", "raw"] and (log.DEBUG or not k.startswith("_"))
|
|
170
|
+
k: v for k, v in resp.__dict__.items() if k not in ["output", "raw", "data_key"] and (log.DEBUG or not k.startswith("_"))
|
|
171
171
|
}
|
|
172
172
|
|
|
173
173
|
req = (
|
|
@@ -3,29 +3,34 @@
|
|
|
3
3
|
|
|
4
4
|
from pathlib import Path
|
|
5
5
|
import sys
|
|
6
|
-
from typing import List, Union
|
|
6
|
+
from typing import TYPE_CHECKING, List, Union
|
|
7
7
|
# from typing import List
|
|
8
8
|
import typer
|
|
9
9
|
from rich import print
|
|
10
10
|
from rich.console import Console
|
|
11
|
+
from rich.text import Text
|
|
11
12
|
from jinja2 import FileSystemLoader, Environment
|
|
12
13
|
import yaml
|
|
13
14
|
|
|
14
15
|
# Detect if called from pypi installed package or via cloned github repo (development)
|
|
15
16
|
try:
|
|
16
|
-
from centralcli import utils, cli,
|
|
17
|
+
from centralcli import utils, cli, cleaner, BatchRequest, log
|
|
17
18
|
except (ImportError, ModuleNotFoundError) as e:
|
|
18
19
|
pkg_dir = Path(__file__).absolute().parent
|
|
19
20
|
if pkg_dir.name == "centralcli":
|
|
20
21
|
sys.path.insert(0, str(pkg_dir.parent))
|
|
21
|
-
from centralcli import utils, cli,
|
|
22
|
+
from centralcli import utils, cli, cleaner, BatchRequest, log
|
|
22
23
|
else:
|
|
23
24
|
print(pkg_dir.parts)
|
|
24
25
|
raise e
|
|
25
26
|
|
|
26
|
-
from
|
|
27
|
-
from
|
|
27
|
+
from .constants import IdenMetaVars, DevTypes, GatewayRole, state_abbrev_to_pretty
|
|
28
|
+
from . import render
|
|
28
29
|
from .cache import CacheTemplate
|
|
30
|
+
from .caas import CaasAPI
|
|
31
|
+
|
|
32
|
+
if TYPE_CHECKING:
|
|
33
|
+
from .cache import CacheDevice, CacheGroup
|
|
29
34
|
|
|
30
35
|
|
|
31
36
|
SPIN_TXT_AUTH = "Establishing Session with Aruba Central API Gateway..."
|
|
@@ -277,7 +282,6 @@ def generate_template(template_file: Union[Path, str], var_file: Union[Path, str
|
|
|
277
282
|
@app.command("config")
|
|
278
283
|
def config_(
|
|
279
284
|
group_dev: str = cli.arguments.group_dev,
|
|
280
|
-
# TODO collect multi-line input as option to paste in config
|
|
281
285
|
cli_file: Path = typer.Argument(..., help="File containing desired config/template in CLI format.", exists=True, autocompletion=lambda incomplete: tuple(), show_default=False,),
|
|
282
286
|
var_file: Path = typer.Argument(None, help="File containing variables for j2 config template.", exists=True, autocompletion=lambda incomplete: tuple(), show_default=False,),
|
|
283
287
|
do_gw: bool = typer.Option(None, "--gw", help="Update group level config for gateways."),
|
|
@@ -289,18 +293,12 @@ def config_(
|
|
|
289
293
|
) -> None:
|
|
290
294
|
"""Update group or device level config (ap or gw).
|
|
291
295
|
"""
|
|
292
|
-
group_dev:
|
|
296
|
+
group_dev: CacheDevice | CacheGroup = cli.cache.get_identifier(group_dev, qry_funcs=["group", "dev"], device_type=["ap", "gw"])
|
|
293
297
|
config_out = utils.generate_template(cli_file, var_file=var_file)
|
|
294
298
|
cli_cmds = utils.validate_config(config_out)
|
|
295
299
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
console.begin_capture()
|
|
299
|
-
console.rule("Configuration to be sent")
|
|
300
|
-
console.print("\n".join([f"[green]{line}[/green]" for line in cli_cmds]))
|
|
301
|
-
console.rule()
|
|
302
|
-
console.print(f"\nUpdating {'group' if group_dev.is_group else group_dev.generic_type.upper()} [cyan]{group_dev.name}[/]")
|
|
303
|
-
_msg = console.end_capture()
|
|
300
|
+
output = render.output(cli_cmds)
|
|
301
|
+
output = Text.from_ansi(output.tty)
|
|
304
302
|
|
|
305
303
|
if group_dev.is_group:
|
|
306
304
|
device = None
|
|
@@ -313,7 +311,7 @@ def config_(
|
|
|
313
311
|
if device and device.generic_type != "gw":
|
|
314
312
|
cli.exit(f"Invalid input: --gw option conflicts with {device.name} which is an {device.generic_type}")
|
|
315
313
|
use_caas = True
|
|
316
|
-
caasapi =
|
|
314
|
+
caasapi = CaasAPI(central=cli.central)
|
|
317
315
|
node_iden = group_dev.name if group_dev.is_group else group_dev.mac
|
|
318
316
|
elif do_ap or (device and device.generic_type == "ap"):
|
|
319
317
|
if device and device.generic_type != "ap":
|
|
@@ -321,7 +319,10 @@ def config_(
|
|
|
321
319
|
use_caas = False
|
|
322
320
|
node_iden = group_dev.name if group_dev.is_group else group_dev.serial
|
|
323
321
|
|
|
324
|
-
|
|
322
|
+
cli.console.rule("Configuration to be sent")
|
|
323
|
+
cli.console.print(output, emoji=False)
|
|
324
|
+
cli.console.rule()
|
|
325
|
+
cli.console.print(f"\nUpdating {'group' if group_dev.is_group else group_dev.generic_type.upper()} [cyan]{group_dev.name}[/]")
|
|
325
326
|
if cli.confirm(yes):
|
|
326
327
|
if use_caas:
|
|
327
328
|
resp = cli.central.request(caasapi.send_commands, node_iden, cli_cmds)
|
|
@@ -329,8 +330,7 @@ def config_(
|
|
|
329
330
|
else:
|
|
330
331
|
# FIXME this is OK for group level ap config , for AP this method is not valid
|
|
331
332
|
if group_dev.is_dev:
|
|
332
|
-
|
|
333
|
-
raise typer.Exit(1)
|
|
333
|
+
cli.exit("Not Implemented yet for AP device level updates")
|
|
334
334
|
resp = cli.central.request(cli.central.replace_ap_config, node_iden, cli_cmds)
|
|
335
335
|
cli.display_results(resp, tablefmt="action")
|
|
336
336
|
|
|
@@ -411,7 +411,7 @@ class Client(BaseModel):
|
|
|
411
411
|
@classmethod
|
|
412
412
|
def pretty_dt(cls, dt: datetime) -> DateTime:
|
|
413
413
|
if dt is None: # TODO all with potential for there not to be a value need this
|
|
414
|
-
return None
|
|
414
|
+
return DateTime(None, "timediff") # resolves PydanticSerializationError when model_dump_json is called on client with None for last_connected (Failed)
|
|
415
415
|
|
|
416
416
|
return DateTime(dt.timestamp(), "timediff")
|
|
417
417
|
|
|
@@ -383,7 +383,8 @@ class Response:
|
|
|
383
383
|
r = r.replace("message: ", "").replace(self.output["message"], f'[red italic]{self.output["message"]}[/]')
|
|
384
384
|
|
|
385
385
|
r = r.replace("failed:", "[red]failed[/]:").replace("FAILED", "[red]FAILED[/red]").replace("failed_devices", "[red]failed_devices[/]").replace("INVALID", "[red]INVALID[/]")
|
|
386
|
-
r = r.replace("SUCCESS", "[bright_green]SUCCESS[/]").replace("Success", "[bright_green]Success[/]").replace("
|
|
386
|
+
r = r.replace("SUCCESS", "[bright_green]SUCCESS[/]").replace("Success", "[bright_green]Success[/]").replace("Success[/]fully", "Successfully[/]")
|
|
387
|
+
r = r.replace("Success", "[bright_green]Success[/]").replace("success", "[bright_green]success[/]").replace("succeeded_devices", "[bright_green]succeeded_devices[/]")
|
|
387
388
|
r = r.replace("invalid_device", "[red]invalid_device[/]").replace("blocked_device", "[red]blocked_device[/red]").replace("ATHENA_ERROR_DEVICE_ALREADY_EXIST", "[italic dark_orange3]Device already exists[/]")
|
|
388
389
|
|
|
389
390
|
# sanitize sensitive data for demos
|
|
@@ -589,3 +589,10 @@ class ImportExamples:
|
|
|
589
589
|
if key not in self.__dict__.keys():
|
|
590
590
|
log.error(f"An attempt was made to get {key} attr from ImportExamples which is not defined.")
|
|
591
591
|
return f":warning: [bright_red]Error[/] no str defined for [cyan]ImportExamples.{key}[/]"
|
|
592
|
+
|
|
593
|
+
cron_weekly = """#!/usr/bin/env bash
|
|
594
|
+
|
|
595
|
+
/bin/su -c "{{py_path}} {{exec_path}} refresh token -d {{accounts}}" {{user}} &&
|
|
596
|
+
logger -t centralcli "Token Refreshed via cron" ||
|
|
597
|
+
logger -t centralcli "Token Refresh returned error"
|
|
598
|
+
"""
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "centralcli"
|
|
3
|
-
version = "7.2.
|
|
3
|
+
version = "7.2.4"
|
|
4
4
|
description = "A CLI for interacting with Aruba Central (Cloud Management Platform). Facilitates bulk imports, exports, reporting. A handy tool if you have devices managed by Aruba Central."
|
|
5
5
|
license = "MIT"
|
|
6
6
|
authors = ["Wade Wells (Pack3tL0ss) <wade@consolepi.org>"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|