ui-cli 1.2.1__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.
Files changed (46) hide show
  1. ui_cli/__init__.py +31 -0
  2. ui_cli/client.py +269 -0
  3. ui_cli/commands/__init__.py +1 -0
  4. ui_cli/commands/devices.py +187 -0
  5. ui_cli/commands/groups.py +503 -0
  6. ui_cli/commands/hosts.py +114 -0
  7. ui_cli/commands/isp.py +100 -0
  8. ui_cli/commands/local/__init__.py +63 -0
  9. ui_cli/commands/local/apgroups.py +445 -0
  10. ui_cli/commands/local/clients.py +1537 -0
  11. ui_cli/commands/local/config.py +758 -0
  12. ui_cli/commands/local/devices.py +570 -0
  13. ui_cli/commands/local/dpi.py +369 -0
  14. ui_cli/commands/local/events.py +289 -0
  15. ui_cli/commands/local/firewall.py +285 -0
  16. ui_cli/commands/local/health.py +195 -0
  17. ui_cli/commands/local/networks.py +426 -0
  18. ui_cli/commands/local/portfwd.py +153 -0
  19. ui_cli/commands/local/stats.py +234 -0
  20. ui_cli/commands/local/utils.py +85 -0
  21. ui_cli/commands/local/vouchers.py +410 -0
  22. ui_cli/commands/local/wan.py +302 -0
  23. ui_cli/commands/local/wlans.py +257 -0
  24. ui_cli/commands/mcp.py +416 -0
  25. ui_cli/commands/sdwan.py +168 -0
  26. ui_cli/commands/sites.py +65 -0
  27. ui_cli/commands/speedtest.py +192 -0
  28. ui_cli/commands/status.py +410 -0
  29. ui_cli/commands/version.py +13 -0
  30. ui_cli/config.py +106 -0
  31. ui_cli/groups.py +567 -0
  32. ui_cli/local_client.py +897 -0
  33. ui_cli/main.py +61 -0
  34. ui_cli/models.py +188 -0
  35. ui_cli/output.py +251 -0
  36. ui_cli-1.2.1.dist-info/METADATA +1315 -0
  37. ui_cli-1.2.1.dist-info/RECORD +46 -0
  38. ui_cli-1.2.1.dist-info/WHEEL +4 -0
  39. ui_cli-1.2.1.dist-info/entry_points.txt +3 -0
  40. ui_cli-1.2.1.dist-info/licenses/LICENSE +21 -0
  41. ui_mcp/ARCHITECTURE.md +243 -0
  42. ui_mcp/README.md +235 -0
  43. ui_mcp/__init__.py +7 -0
  44. ui_mcp/__main__.py +10 -0
  45. ui_mcp/cli_runner.py +112 -0
  46. ui_mcp/server.py +468 -0
ui_cli/groups.py ADDED
@@ -0,0 +1,567 @@
1
+ """Client groups management for ui-cli.
2
+
3
+ Groups allow users to organize client devices and perform bulk actions.
4
+ Supports both static (manual membership) and auto (rule-based) groups.
5
+
6
+ Storage: ~/.config/ui-cli/groups.json
7
+ """
8
+
9
+ from pathlib import Path
10
+ from datetime import datetime, timezone
11
+ from typing import Literal
12
+ import json
13
+ import re
14
+ import fnmatch
15
+ from pydantic import BaseModel
16
+
17
+
18
+ class GroupMember(BaseModel):
19
+ """A member of a static group."""
20
+
21
+ mac: str
22
+ alias: str | None = None
23
+
24
+
25
+ class AutoGroupRules(BaseModel):
26
+ """Rules for auto group membership evaluation."""
27
+
28
+ vendor: list[str] | None = None # OUI/manufacturer patterns
29
+ name: list[str] | None = None # Client name patterns
30
+ hostname: list[str] | None = None # Hostname patterns
31
+ network: list[str] | None = None # Network/SSID patterns
32
+ ip: list[str] | None = None # IP address patterns/ranges
33
+ mac: list[str] | None = None # MAC prefix patterns
34
+ conn_type: list[str] | None = None # "wired" or "wireless"
35
+
36
+
37
+ class Group(BaseModel):
38
+ """A client group definition."""
39
+
40
+ name: str
41
+ description: str | None = None
42
+ type: Literal["static", "auto"] = "static"
43
+ members: list[GroupMember] | None = None # For static groups
44
+ rules: AutoGroupRules | None = None # For auto groups
45
+ created_at: datetime
46
+ updated_at: datetime
47
+
48
+
49
+ class GroupsFile(BaseModel):
50
+ """Root structure for groups.json file."""
51
+
52
+ version: int = 1
53
+ groups: dict[str, Group] = {}
54
+
55
+
56
+ class GroupManager:
57
+ """Manages client groups stored in ~/.config/ui-cli/groups.json"""
58
+
59
+ def __init__(self):
60
+ self._path = Path.home() / ".config" / "ui-cli" / "groups.json"
61
+ self._data: GroupsFile | None = None
62
+
63
+ @property
64
+ def data(self) -> GroupsFile:
65
+ """Lazy-load groups data."""
66
+ if self._data is None:
67
+ self._load()
68
+ return self._data
69
+
70
+ def _load(self) -> None:
71
+ """Load groups from disk."""
72
+ if self._path.exists():
73
+ try:
74
+ raw = json.loads(self._path.read_text())
75
+ self._data = GroupsFile(**raw)
76
+ except (json.JSONDecodeError, ValueError):
77
+ # Corrupted file, start fresh
78
+ self._data = GroupsFile()
79
+ else:
80
+ self._data = GroupsFile()
81
+
82
+ def _save(self) -> None:
83
+ """Save groups to disk."""
84
+ self._path.parent.mkdir(parents=True, exist_ok=True)
85
+ self._path.write_text(
86
+ json.dumps(self.data.model_dump(), indent=2, default=str)
87
+ )
88
+
89
+ @staticmethod
90
+ def slugify(name: str) -> str:
91
+ """Convert display name to slug.
92
+
93
+ Example: 'Kids Devices' -> 'kids-devices'
94
+ """
95
+ slug = name.lower().strip()
96
+ slug = re.sub(r"[^a-z0-9]+", "-", slug)
97
+ return slug.strip("-")
98
+
99
+ @staticmethod
100
+ def normalize_mac(mac: str) -> str:
101
+ """Normalize MAC address to uppercase with colons.
102
+
103
+ Handles formats:
104
+ - AA:BB:CC:DD:EE:FF
105
+ - AA-BB-CC-DD-EE-FF
106
+ - AABBCCDDEEFF
107
+ - aa:bb:cc:dd:ee:ff
108
+ """
109
+ mac = mac.upper().replace("-", ":").replace(".", ":")
110
+ # Handle formats like AABBCCDDEEFF
111
+ if ":" not in mac and len(mac) == 12:
112
+ mac = ":".join(mac[i : i + 2] for i in range(0, 12, 2))
113
+ return mac
114
+
115
+ def _resolve_group(self, name_or_slug: str) -> str | None:
116
+ """Resolve name or slug to slug, return None if not found."""
117
+ slug = self.slugify(name_or_slug)
118
+ if slug in self.data.groups:
119
+ return slug
120
+ # Try exact name match
121
+ for s, g in self.data.groups.items():
122
+ if g.name.lower() == name_or_slug.lower():
123
+ return s
124
+ return None
125
+
126
+ # -------------------------------------------------------------------------
127
+ # Group CRUD
128
+ # -------------------------------------------------------------------------
129
+
130
+ def list_groups(self) -> list[tuple[str, Group]]:
131
+ """List all groups as (slug, Group) tuples."""
132
+ return list(self.data.groups.items())
133
+
134
+ def get_group(self, name_or_slug: str) -> tuple[str, Group] | None:
135
+ """Get group by name or slug.
136
+
137
+ Returns (slug, Group) or None if not found.
138
+ """
139
+ slug = self._resolve_group(name_or_slug)
140
+ if slug:
141
+ return (slug, self.data.groups[slug])
142
+ return None
143
+
144
+ def create_group(
145
+ self,
146
+ name: str,
147
+ description: str | None = None,
148
+ group_type: Literal["static", "auto"] = "static",
149
+ rules: AutoGroupRules | None = None,
150
+ ) -> tuple[str, Group]:
151
+ """Create a new group.
152
+
153
+ Returns (slug, Group).
154
+ Raises ValueError if group already exists.
155
+ """
156
+ slug = self.slugify(name)
157
+ if slug in self.data.groups:
158
+ raise ValueError(f"Group '{name}' already exists")
159
+
160
+ now = datetime.now(timezone.utc)
161
+ group = Group(
162
+ name=name,
163
+ description=description,
164
+ type=group_type,
165
+ members=[] if group_type == "static" else None,
166
+ rules=rules if group_type == "auto" else None,
167
+ created_at=now,
168
+ updated_at=now,
169
+ )
170
+ self.data.groups[slug] = group
171
+ self._save()
172
+ return (slug, group)
173
+
174
+ def delete_group(self, name_or_slug: str) -> bool:
175
+ """Delete a group.
176
+
177
+ Returns True if deleted, False if not found.
178
+ """
179
+ slug = self._resolve_group(name_or_slug)
180
+ if not slug:
181
+ return False
182
+ del self.data.groups[slug]
183
+ self._save()
184
+ return True
185
+
186
+ def update_group(
187
+ self,
188
+ name_or_slug: str,
189
+ new_name: str | None = None,
190
+ description: str | None = ..., # type: ignore # Use ... to distinguish from None
191
+ ) -> tuple[str, Group]:
192
+ """Update group name and/or description.
193
+
194
+ Returns (slug, Group).
195
+ Raises ValueError if group not found.
196
+ """
197
+ slug = self._resolve_group(name_or_slug)
198
+ if not slug:
199
+ raise ValueError(f"Group '{name_or_slug}' not found")
200
+
201
+ group = self.data.groups[slug]
202
+
203
+ # Handle rename (may change slug)
204
+ if new_name:
205
+ group.name = new_name
206
+ new_slug = self.slugify(new_name)
207
+ if new_slug != slug:
208
+ self.data.groups[new_slug] = group
209
+ del self.data.groups[slug]
210
+ slug = new_slug
211
+
212
+ # Handle description update (... means not provided)
213
+ if description is not ...:
214
+ group.description = description
215
+
216
+ group.updated_at = datetime.now(timezone.utc)
217
+ self._save()
218
+ return (slug, group)
219
+
220
+ # -------------------------------------------------------------------------
221
+ # Member CRUD Operations (Static Groups)
222
+ # -------------------------------------------------------------------------
223
+
224
+ def add_member(
225
+ self,
226
+ name_or_slug: str,
227
+ mac: str,
228
+ alias: str | None = None,
229
+ ) -> Group:
230
+ """Add a member to a static group.
231
+
232
+ If member already exists, updates the alias if provided.
233
+ Raises ValueError if group not found or is auto group.
234
+ """
235
+ slug = self._resolve_group(name_or_slug)
236
+ if not slug:
237
+ raise ValueError(f"Group '{name_or_slug}' not found")
238
+
239
+ group = self.data.groups[slug]
240
+ if group.type != "static":
241
+ raise ValueError("Cannot add members to auto groups")
242
+
243
+ mac = self.normalize_mac(mac)
244
+
245
+ # Check if already exists - update alias if so
246
+ if group.members:
247
+ for member in group.members:
248
+ if member.mac == mac:
249
+ if alias:
250
+ member.alias = alias
251
+ group.updated_at = datetime.now(timezone.utc)
252
+ self._save()
253
+ return group
254
+
255
+ # Add new member
256
+ if group.members is None:
257
+ group.members = []
258
+ group.members.append(GroupMember(mac=mac, alias=alias))
259
+ group.updated_at = datetime.now(timezone.utc)
260
+ self._save()
261
+ return group
262
+
263
+ def get_member(self, name_or_slug: str, identifier: str) -> GroupMember | None:
264
+ """Get a member by MAC or alias.
265
+
266
+ Returns GroupMember or None if not found.
267
+ """
268
+ slug = self._resolve_group(name_or_slug)
269
+ if not slug:
270
+ raise ValueError(f"Group '{name_or_slug}' not found")
271
+
272
+ group = self.data.groups[slug]
273
+ if group.type != "static" or not group.members:
274
+ return None
275
+
276
+ identifier_mac = self.normalize_mac(identifier)
277
+ for member in group.members:
278
+ if member.mac == identifier_mac or member.alias == identifier:
279
+ return member
280
+ return None
281
+
282
+ def update_member(
283
+ self,
284
+ name_or_slug: str,
285
+ identifier: str,
286
+ alias: str | None = ..., # type: ignore
287
+ ) -> bool:
288
+ """Update a member's alias.
289
+
290
+ Pass alias=None to clear the alias.
291
+ Returns True if updated, False if member not found.
292
+ """
293
+ slug = self._resolve_group(name_or_slug)
294
+ if not slug:
295
+ raise ValueError(f"Group '{name_or_slug}' not found")
296
+
297
+ group = self.data.groups[slug]
298
+ if group.type != "static" or not group.members:
299
+ return False
300
+
301
+ identifier_mac = self.normalize_mac(identifier)
302
+ for member in group.members:
303
+ if member.mac == identifier_mac or member.alias == identifier:
304
+ if alias is not ...:
305
+ member.alias = alias
306
+ group.updated_at = datetime.now(timezone.utc)
307
+ self._save()
308
+ return True
309
+ return False
310
+
311
+ def remove_member(self, name_or_slug: str, identifier: str) -> bool:
312
+ """Remove a member by MAC or alias.
313
+
314
+ Returns True if removed, False if not found.
315
+ """
316
+ slug = self._resolve_group(name_or_slug)
317
+ if not slug:
318
+ raise ValueError(f"Group '{name_or_slug}' not found")
319
+
320
+ group = self.data.groups[slug]
321
+ if group.type != "static" or not group.members:
322
+ return False
323
+
324
+ identifier_mac = self.normalize_mac(identifier)
325
+ for i, member in enumerate(group.members):
326
+ if member.mac == identifier_mac or member.alias == identifier:
327
+ group.members.pop(i)
328
+ group.updated_at = datetime.now(timezone.utc)
329
+ self._save()
330
+ return True
331
+ return False
332
+
333
+ def list_members(self, name_or_slug: str) -> list[GroupMember]:
334
+ """List all members in a static group.
335
+
336
+ Raises ValueError if group not found or is auto group.
337
+ """
338
+ slug = self._resolve_group(name_or_slug)
339
+ if not slug:
340
+ raise ValueError(f"Group '{name_or_slug}' not found")
341
+
342
+ group = self.data.groups[slug]
343
+ if group.type != "static":
344
+ raise ValueError("Auto groups have dynamic membership")
345
+ return group.members or []
346
+
347
+ def clear_members(self, name_or_slug: str) -> bool:
348
+ """Clear all members from a static group.
349
+
350
+ Raises ValueError if group not found or is auto group.
351
+ """
352
+ slug = self._resolve_group(name_or_slug)
353
+ if not slug:
354
+ raise ValueError(f"Group '{name_or_slug}' not found")
355
+
356
+ group = self.data.groups[slug]
357
+ if group.type != "static":
358
+ raise ValueError("Cannot clear members from auto groups")
359
+
360
+ group.members = []
361
+ group.updated_at = datetime.now(timezone.utc)
362
+ self._save()
363
+ return True
364
+
365
+ def get_member_macs(self, name_or_slug: str) -> list[str]:
366
+ """Get list of MAC addresses in a static group."""
367
+ result = self.get_group(name_or_slug)
368
+ if not result:
369
+ raise ValueError(f"Group '{name_or_slug}' not found")
370
+ _, group = result
371
+ if group.type != "static" or not group.members:
372
+ return []
373
+ return [m.mac for m in group.members]
374
+
375
+ # -------------------------------------------------------------------------
376
+ # Auto Group Operations
377
+ # -------------------------------------------------------------------------
378
+
379
+ def set_rules(self, name_or_slug: str, rules: AutoGroupRules) -> Group:
380
+ """Set rules for an auto group.
381
+
382
+ Raises ValueError if group not found or is static group.
383
+ """
384
+ slug = self._resolve_group(name_or_slug)
385
+ if not slug:
386
+ raise ValueError(f"Group '{name_or_slug}' not found")
387
+
388
+ group = self.data.groups[slug]
389
+ if group.type != "auto":
390
+ raise ValueError("Cannot set rules on static groups")
391
+
392
+ group.rules = rules
393
+ group.updated_at = datetime.now(timezone.utc)
394
+ self._save()
395
+ return group
396
+
397
+ @staticmethod
398
+ def pattern_matches(pattern: str, value: str | None) -> bool:
399
+ """Check if value matches pattern.
400
+
401
+ Pattern syntax:
402
+ - Exact: "Apple" - case-insensitive exact match
403
+ - Wildcard: "*phone*" - uses fnmatch
404
+ - Regex: "~^iPhone-[0-9]+" - prefix with ~ for regex
405
+ - Multiple: "Apple,Samsung" - comma-separated, OR logic
406
+ """
407
+ if not pattern or not value:
408
+ return False
409
+
410
+ # Handle multiple patterns (OR logic)
411
+ if "," in pattern and not pattern.startswith("~"):
412
+ patterns = [p.strip() for p in pattern.split(",")]
413
+ return any(GroupManager.pattern_matches(p, value) for p in patterns)
414
+
415
+ # Regex pattern (prefix with ~)
416
+ if pattern.startswith("~"):
417
+ try:
418
+ return bool(re.search(pattern[1:], value, re.IGNORECASE))
419
+ except re.error:
420
+ return False
421
+
422
+ # Wildcard pattern
423
+ if "*" in pattern or "?" in pattern:
424
+ return fnmatch.fnmatch(value.lower(), pattern.lower())
425
+
426
+ # Exact match (case-insensitive)
427
+ return value.lower() == pattern.lower()
428
+
429
+ @staticmethod
430
+ def ip_matches(pattern: str, ip: str | None) -> bool:
431
+ """Check if IP matches pattern.
432
+
433
+ Supports:
434
+ - CIDR: 192.168.1.0/24
435
+ - Range: 192.168.1.100-200 or 192.168.1.100-192.168.1.200
436
+ - Wildcard: 192.168.1.*
437
+ """
438
+ if not pattern or not ip:
439
+ return False
440
+
441
+ import ipaddress
442
+
443
+ # CIDR notation: 192.168.1.0/24
444
+ if "/" in pattern:
445
+ try:
446
+ network = ipaddress.ip_network(pattern, strict=False)
447
+ return ipaddress.ip_address(ip) in network
448
+ except ValueError:
449
+ return False
450
+
451
+ # Range: 192.168.1.100-200
452
+ if "-" in pattern and not pattern.startswith("-"):
453
+ try:
454
+ base, end = pattern.rsplit("-", 1)
455
+ if "." in end:
456
+ # Full IP range: 192.168.1.100-192.168.1.200
457
+ start_ip = ipaddress.ip_address(base)
458
+ end_ip = ipaddress.ip_address(end)
459
+ else:
460
+ # Partial range: 192.168.1.100-200
461
+ start_ip = ipaddress.ip_address(base)
462
+ base_parts = base.rsplit(".", 1)
463
+ end_ip = ipaddress.ip_address(f"{base_parts[0]}.{end}")
464
+ target = ipaddress.ip_address(ip)
465
+ return start_ip <= target <= end_ip
466
+ except ValueError:
467
+ return False
468
+
469
+ # Wildcard: 192.168.1.*
470
+ return GroupManager.pattern_matches(pattern, ip)
471
+
472
+ def evaluate_auto_group(
473
+ self,
474
+ name_or_slug: str,
475
+ clients: list[dict],
476
+ ) -> list[dict]:
477
+ """Evaluate auto group rules against client list.
478
+
479
+ Returns list of matching clients.
480
+ Raises ValueError if group not found.
481
+ """
482
+ result = self.get_group(name_or_slug)
483
+ if not result:
484
+ raise ValueError(f"Group '{name_or_slug}' not found")
485
+
486
+ _, group = result
487
+ if group.type != "auto" or not group.rules:
488
+ return []
489
+
490
+ matching = []
491
+ for client in clients:
492
+ if self._client_matches_rules(client, group.rules):
493
+ matching.append(client)
494
+ return matching
495
+
496
+ def _client_matches_rules(self, client: dict, rules: AutoGroupRules) -> bool:
497
+ """Check if client matches all rules (AND logic between rule types)."""
498
+ # Vendor (OUI)
499
+ if rules.vendor:
500
+ oui = client.get("oui", "")
501
+ if not any(self.pattern_matches(p, oui) for p in rules.vendor):
502
+ return False
503
+
504
+ # Client name
505
+ if rules.name:
506
+ name = client.get("name") or client.get("hostname") or ""
507
+ if not any(self.pattern_matches(p, name) for p in rules.name):
508
+ return False
509
+
510
+ # Hostname
511
+ if rules.hostname:
512
+ hostname = client.get("hostname", "")
513
+ if not any(self.pattern_matches(p, hostname) for p in rules.hostname):
514
+ return False
515
+
516
+ # Network/SSID
517
+ if rules.network:
518
+ network = client.get("essid") or client.get("network", "")
519
+ if not any(self.pattern_matches(p, network) for p in rules.network):
520
+ return False
521
+
522
+ # IP address
523
+ if rules.ip:
524
+ ip = client.get("ip", "")
525
+ if not any(self.ip_matches(p, ip) for p in rules.ip):
526
+ return False
527
+
528
+ # MAC prefix
529
+ if rules.mac:
530
+ mac = client.get("mac", "")
531
+ if not any(mac.upper().startswith(p.upper().replace("-", ":")) for p in rules.mac):
532
+ return False
533
+
534
+ # Connection type
535
+ if rules.conn_type:
536
+ is_wired = client.get("is_wired", False)
537
+ client_type = "wired" if is_wired else "wireless"
538
+ if client_type not in [t.lower() for t in rules.conn_type]:
539
+ return False
540
+
541
+ return True
542
+
543
+ # -------------------------------------------------------------------------
544
+ # Import/Export
545
+ # -------------------------------------------------------------------------
546
+
547
+ def export_groups(self) -> dict:
548
+ """Export all groups as dict."""
549
+ return self.data.model_dump()
550
+
551
+ def import_groups(self, data: dict, replace: bool = False) -> int:
552
+ """Import groups from dict.
553
+
554
+ Args:
555
+ data: Groups data to import
556
+ replace: If True, replace all existing groups. If False, merge.
557
+
558
+ Returns count of imported groups.
559
+ """
560
+ imported = GroupsFile(**data)
561
+ if replace:
562
+ self._data = imported
563
+ else:
564
+ for slug, group in imported.groups.items():
565
+ self.data.groups[slug] = group
566
+ self._save()
567
+ return len(imported.groups)